pi-mega-compact 0.5.1 → 0.6.0

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.
@@ -31,3 +31,51 @@ export function preserveRecentForPressure(pressure, preserveRecent, preserveRece
31
31
  const v = Math.round(preserveRecent - (preserveRecent - preserveRecentMin) * p);
32
32
  return Math.max(preserveRecentMin, Math.min(preserveRecent, v));
33
33
  }
34
+ /** Clamp a pressure ratio into [0, 1]. */
35
+ function clamp01(p) {
36
+ if (!Number.isFinite(p))
37
+ return 0;
38
+ return p < 0 ? 0 : p > 1 ? 1 : p;
39
+ }
40
+ /**
41
+ * Pressure as a 0–1 ratio from live token usage relative to the compaction
42
+ * threshold. Cheaper + more direct than deriving from a usage percentage when
43
+ * we already have both numbers (the context handler does). Re-exports
44
+ * `pressureFromPct` covers the percentage-only path. (S24.)
45
+ */
46
+ export function pressureRatio(currentTokens, thresholdTokens) {
47
+ if (!Number.isFinite(currentTokens) || currentTokens <= 0)
48
+ return 0;
49
+ const t = Number.isFinite(thresholdTokens) && thresholdTokens > 0 ? thresholdTokens : 0;
50
+ return clamp01(t > 0 ? currentTokens / t : 0);
51
+ }
52
+ /** Map a 0–1 pressure ratio to a discrete band. (S24.) */
53
+ export function pressureBand(pressure) {
54
+ const p = clamp01(pressure);
55
+ if (p >= 1.0)
56
+ return "mega";
57
+ if (p >= 0.9)
58
+ return "ultra";
59
+ if (p >= 0.75)
60
+ return "high";
61
+ if (p >= 0.5)
62
+ return "medium";
63
+ return "low";
64
+ }
65
+ /**
66
+ * Memory auto-review cadence (in turns) for a given pressure band. As pressure
67
+ * climbs, the conversation is reviewed more often so durable memories keep pace
68
+ * with the faster context churn. Returns a divisor used as
69
+ * `turn % cadence === 0`. Always >= 1. (S24 — memory cadence tie-in.)
70
+ */
71
+ export function memoryReviewCadence(band, baseInterval) {
72
+ const base = baseInterval >= 1 ? baseInterval : 1;
73
+ switch (band) {
74
+ case "mega": return Math.max(1, Math.round(base / 5));
75
+ case "ultra": return Math.max(1, Math.round(base / 3));
76
+ case "high": return Math.max(1, Math.round(base / 2));
77
+ case "medium": return Math.max(1, Math.round((base * 2) / 3));
78
+ case "low":
79
+ default: return base;
80
+ }
81
+ }
@@ -4,7 +4,7 @@ import { mkdtempSync, rmSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { applyMemoryOps } from "./memoryOps.js";
7
- import { addMemory, listMemories } from "./store/sqlite.js";
7
+ import { addMemory, listMemories, replaceMemory, referenceMemory, MEMORY_MAX_CHARS, MEMORY_MAX_ROWS, } from "./store/sqlite.js";
8
8
  const baseTmp = mkdtempSync(join(tmpdir(), "mc-memops-"));
9
9
  test("applyMemoryOps: ADD inserts a new memory", async () => {
10
10
  const dir = join(baseTmp, "add");
@@ -36,6 +36,55 @@ test("applyMemoryOps: REMOVE deletes the matching memory", async () => {
36
36
  const rows = listMemories(null, 50, dir);
37
37
  assert.ok(!rows.some((m) => /obsolete note/.test(m.content)), "removed");
38
38
  });
39
+ test("S24: addMemory truncates content to MEMORY_MAX_CHARS", () => {
40
+ const dir = join(baseTmp, "cap");
41
+ const big = "x".repeat(MEMORY_MAX_CHARS + 5000);
42
+ const id = addMemory({ content: big, category: "note" }, null, dir);
43
+ const rows = listMemories(null, 50, dir);
44
+ const row = rows.find((m) => m.id === id);
45
+ assert.ok(row, "row present");
46
+ assert.ok(row.content.length <= MEMORY_MAX_CHARS + 12, "content capped (incl. marker)");
47
+ assert.ok(row.content.endsWith("…[truncated]"), "marker appended");
48
+ });
49
+ test("S24: replaceMemory also truncates oversized content", () => {
50
+ const dir = join(baseTmp, "capreplace");
51
+ const id = addMemory({ content: "short", category: "note" }, null, dir);
52
+ const big = "y".repeat(MEMORY_MAX_CHARS + 1000);
53
+ replaceMemory(id, { content: big }, dir);
54
+ const rows = listMemories(null, 50, dir);
55
+ const row = rows.find((m) => m.id === id);
56
+ assert.ok(row, "row present");
57
+ assert.ok(row.content.length <= MEMORY_MAX_CHARS + 12, "replaced content capped");
58
+ assert.ok(row.content.endsWith("…[truncated]"), "marker appended");
59
+ });
60
+ test("S24: addMemory evicts LRU rows past MEMORY_MAX_ROWS per repo", () => {
61
+ const dir = join(baseTmp, "lru");
62
+ const n = MEMORY_MAX_ROWS;
63
+ const seeds = n - 2;
64
+ for (let i = 0; i < seeds; i++)
65
+ addMemory({ content: `seed-${i}`, category: "note" }, null, dir);
66
+ const keep1 = addMemory({ content: "keep-recent-1", category: "note" }, null, dir);
67
+ const keep2 = addMemory({ content: "keep-recent-2", category: "note" }, null, dir);
68
+ // Mark the two as referenced so the LRU eviction spares them (they get a
69
+ // higher last_referenced than the un-referenced seeds).
70
+ assert.ok(referenceMemory(keep1, dir), "reference keep1");
71
+ assert.ok(referenceMemory(keep2, dir), "reference keep2");
72
+ // Insert 3 more — 3 over the cap across the inserts. The two referenced rows
73
+ // must survive; only un-referenced (oldest) seeds should be evicted.
74
+ addMemory({ content: "new-1", category: "note" }, null, dir);
75
+ addMemory({ content: "new-2", category: "note" }, null, dir);
76
+ addMemory({ content: "new-3", category: "note" }, null, dir);
77
+ const rows = listMemories(null, 1000, dir);
78
+ assert.equal(rows.length, n, "row count clamped to MEMORY_MAX_ROWS");
79
+ assert.ok(rows.some((m) => /keep-recent-1/.test(m.content)), "referenced row survived");
80
+ assert.ok(rows.some((m) => /keep-recent-2/.test(m.content)), "referenced row survived");
81
+ assert.ok(rows.some((m) => /new-3/.test(m.content)), "newest row present");
82
+ const seedRows = rows.filter((m) => /seed-/.test(m.content));
83
+ // 3 rows were evicted (the inserts pushed 3 past the cap); all evicted rows
84
+ // must be un-referenced seeds — the referenced rows survived above.
85
+ assert.equal(seedRows.length, seeds - 3, "exactly 3 oldest un-referenced seeds evicted");
86
+ assert.ok(!seedRows.some((m) => /seed-0/.test(m.content)), "oldest un-referenced seed evicted");
87
+ });
39
88
  test("cleanup memops", () => {
40
89
  rmSync(baseTmp, { recursive: true, force: true });
41
90
  });
@@ -113,3 +113,27 @@ test("pressureFromPct + preserveRecentForPressure scale with context (Fix E)", a
113
113
  assert.equal(preserveRecentForPressure(0.5, 4, 2), 3, "p=0.5 → interpolates");
114
114
  assert.ok(preserveRecentForPressure(1, 4, 2) >= 2, "never below floor");
115
115
  });
116
+ test("S24: pressureRatio + pressureBand + memoryReviewCadence unify the signal", async () => {
117
+ const { pressureRatio, pressureBand, memoryReviewCadence } = await import("../config.js");
118
+ // pressureRatio: current/threshold, clamped to [0,1].
119
+ assert.equal(pressureRatio(50_000, 100_000), 0.5, "half threshold → 0.5");
120
+ assert.equal(pressureRatio(0, 100_000), 0, "no tokens → 0");
121
+ assert.equal(pressureRatio(10_000_000, 100_000), 1, "over threshold → clamped 1");
122
+ assert.equal(pressureRatio(50_000, 0), 0, "zero threshold → 0");
123
+ assert.equal(pressureRatio(NaN, 100_000), 0, "NaN current → 0");
124
+ // pressureBand: discrete bands drive the toolbar/dashboard tier label.
125
+ assert.equal(pressureBand(0.2), "low");
126
+ assert.equal(pressureBand(0.5), "medium");
127
+ assert.equal(pressureBand(0.75), "high");
128
+ assert.equal(pressureBand(0.9), "ultra");
129
+ assert.equal(pressureBand(1.0), "mega");
130
+ assert.equal(pressureBand(2.0), "mega", "over 1 → mega");
131
+ assert.equal(pressureBand(-1), "low", "below 0 → low");
132
+ // memoryReviewCadence: higher pressure → smaller (more frequent) divisor.
133
+ assert.equal(memoryReviewCadence("low", 10), 10, "low keeps base interval");
134
+ assert.equal(memoryReviewCadence("medium", 10), 7, "medium shortens");
135
+ assert.equal(memoryReviewCadence("high", 10), 5, "high halves");
136
+ assert.equal(memoryReviewCadence("ultra", 10), 3, "ultra shortens more");
137
+ assert.equal(memoryReviewCadence("mega", 10), 2, "mega near base/5");
138
+ assert.equal(memoryReviewCadence("high", 0), 1, "never below 1");
139
+ });
@@ -573,14 +573,71 @@ export function addLesson(sessionId, repo, lesson, stateDir = getStateDir()) {
573
573
  const now = Math.floor(Date.now() / 1000);
574
574
  db.prepare(`INSERT INTO lessons(session_id, repo, lesson, ts) VALUES(?, ?, ?, ?)`).run(normalizeSessionId(sessionId), repo ?? null, lesson, now);
575
575
  }
576
- /** Save a memory to the current repo's store. Returns the new row id. */
576
+ // --- Durable memory (save-to-memory takeover) ---------------------------------
577
+ // One SQLite store for user-saved memories, scoped by repo. Mirrors the
578
+ // lessons/sessions pattern: all state lives in SQLite from day one.
579
+ // S24 storage hardening: keep each memory row bounded so the durable store can
580
+ // never blow a downstream consumer's per-entry buffer (e.g. pi's native
581
+ // file-backed memory caps a single entry at ~5k chars). We truncate content at
582
+ // MEMORY_MAX_CHARS and evict the least-recently-referenced rows past
583
+ // MEMORY_MAX_ROWS per repo via LRU. Both are SQLite-only (PREVENT-PI-004): no
584
+ // file-backed memory is written anywhere.
585
+ export const MEMORY_MAX_CHARS = 4000;
586
+ export const MEMORY_MAX_ROWS = 200;
587
+ /** Truncate memory content to the per-entry cap, preserving a trailing marker. */
588
+ function capMemoryContent(content) {
589
+ if (content.length <= MEMORY_MAX_CHARS)
590
+ return content;
591
+ return content.slice(0, MEMORY_MAX_CHARS) + "…[truncated]";
592
+ }
593
+ /**
594
+ * Evict the least-recently-referenced rows for a repo past MEMORY_MAX_ROWS.
595
+ * LRU key = COALESCE(last_referenced, last_recalled_at, created_at) so a memory
596
+ * that is recalled/referenced survives over a stale one. Best-effort: any error
597
+ * is swallowed by the caller. Repo-scoped so one noisy repo can't evict another.
598
+ */
599
+ function evictMemoryLru(repo, stateDir) {
600
+ const db = openStore(stateDir);
601
+ // SQLite `= NULL` is never true, so the null-repo scope (memories are
602
+ // stateDir-scoped when repo is null — the applyMemoryOps path) needs `IS NULL`.
603
+ const where = repo == null ? "repo IS NULL" : "repo = ?";
604
+ const countRow = repo == null
605
+ ? db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get()
606
+ : db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get(repo);
607
+ const count = countRow.n;
608
+ const over = count - MEMORY_MAX_ROWS;
609
+ if (over <= 0)
610
+ return;
611
+ // Delete the `over` least-recently-used rows. ORDER BY the LRU key ASC, id ASC
612
+ // (id ASC breaks ties deterministically — oldest created first). The `where`
613
+ // clause is a code-controlled constant (never user input) → PREVENT-002 OK.
614
+ const sql = `DELETE FROM memories WHERE ${where} AND id IN (
615
+ SELECT id FROM memories WHERE ${where}
616
+ ORDER BY COALESCE(last_referenced, last_recalled_at, created_at) ASC, id ASC
617
+ LIMIT ?
618
+ )`;
619
+ if (repo == null)
620
+ db.prepare(sql).run(over);
621
+ else
622
+ db.prepare(sql).run(repo, repo, over);
623
+ }
624
+ /** Save a memory to the current repo's store. Returns the new row id.
625
+ * S24 hardening: content is truncated to MEMORY_MAX_CHARS and, once the per-repo
626
+ * row count exceeds MEMORY_MAX_ROWS, the least-recently-used rows are evicted
627
+ * (LRU) so the store stays bounded. */
577
628
  export function addMemory(memory, repo, stateDir = getStateDir()) {
578
629
  const db = openStore(stateDir);
579
630
  const now = Math.floor(Date.now() / 1000);
580
631
  const res = db
581
632
  .prepare(`INSERT INTO memories(repo, kind, content, tags, created_at, last_recalled_at, category, target, source_turn)
582
633
  VALUES(?, ?, ?, ?, ?, NULL, ?, ?, ?)`)
583
- .run(repo ?? null, memory.kind ?? "note", memory.content, JSON.stringify(memory.tags ?? []), now, memory.category ?? null, memory.target ?? null, memory.sourceTurn ?? null);
634
+ .run(repo ?? null, memory.kind ?? "note", capMemoryContent(memory.content), JSON.stringify(memory.tags ?? []), now, memory.category ?? null, memory.target ?? null, memory.sourceTurn ?? null);
635
+ try {
636
+ evictMemoryLru(repo, stateDir);
637
+ }
638
+ catch {
639
+ /* non-fatal: eviction must never fail an add */
640
+ }
584
641
  return Number(res.lastInsertRowid);
585
642
  }
586
643
  /** List recent memories for a repo (or all repos when repo is null). */
@@ -626,7 +683,7 @@ export function replaceMemory(id, patch, stateDir = getStateDir()) {
626
683
  target = COALESCE(?, target),
627
684
  source_turn = COALESCE(?, source_turn)
628
685
  WHERE id = ?`)
629
- .run(patch.kind ?? null, patch.content ?? null, patch.tags ? JSON.stringify(patch.tags) : null, "category" in patch ? (patch.category ?? null) : null, "target" in patch ? (patch.target ?? null) : null, "sourceTurn" in patch ? (patch.sourceTurn ?? null) : null, id);
686
+ .run(patch.kind ?? null, patch.content != null ? capMemoryContent(patch.content) : null, patch.tags ? JSON.stringify(patch.tags) : null, "category" in patch ? (patch.category ?? null) : null, "target" in patch ? (patch.target ?? null) : null, "sourceTurn" in patch ? (patch.sourceTurn ?? null) : null, id);
630
687
  return res.changes > 0;
631
688
  }
632
689
  /** Remove a memory by id. Returns true if a row was deleted. */
@@ -138,6 +138,8 @@ interface Snapshot {
138
138
  version: number;
139
139
  updatedAt: string | null;
140
140
  tier: string;
141
+ presetTier: string;
142
+ pressure: number;
141
143
  config: {
142
144
  fastGatePct: number;
143
145
  thresholdTokens: number;
@@ -217,6 +219,8 @@ function readSnapshot(snapshotPath: string) {
217
219
  version: 1,
218
220
  updatedAt: null,
219
221
  tier: "unknown",
222
+ presetTier: "unknown",
223
+ pressure: 0,
220
224
  config: { fastGatePct: 80, thresholdTokens: 100_000, anchorUserMessages: 1, preserveRecent: 2, auto: true, autoInlineK: 3 },
221
225
  session: { id: null, state: null, persistedThisSession: false, lastCheckpointId: null, lastCompactedFrom: 0 },
222
226
  context: { tokens: null, percent: null, contextWindow: 0 },
@@ -343,7 +347,7 @@ function dashboardHtml(tierName: string): string {
343
347
 
344
348
  <div class="offline-banner" id="offline-banner">Dashboard data unavailable — waiting for a pi session to write snapshot...</div>
345
349
 
346
- <h1><span>mega-compact</span><span class="tier">${tierName}</span><span class="version-pill">v${dashboardServerVersion}</span><span class="model-pill" id="hdr-model">—</span></h1>
350
+ <h1><span>mega-compact</span><span class="tier" id="hdr-tier">${tierName}</span><span class="version-pill">v${dashboardServerVersion}</span><span class="model-pill" id="hdr-model">—</span></h1>
347
351
 
348
352
  <nav class="tabs">
349
353
  <button class="tab active" data-tab="current">Current repo</button>
@@ -405,7 +409,9 @@ function dashboardHtml(tierName: string): string {
405
409
  <div class="card">
406
410
  <h2>Configuration</h2>
407
411
  <div class="conf-grid">
408
- <span class="label">Tier</span><span class="value" id="cf-tier">${tierName}</span>
412
+ <span class="label" title="Live pressure band — climbs low→mega as context fills the window.">Tier (live)</span><span class="value" id="cf-tier">${tierName}</span>
413
+ <span class="label" title="The env-resolved base compaction preset (low/medium/high/ultra/mega) that set the token threshold.">Preset</span><span class="value" id="cf-preset">—</span>
414
+ <span class="label" title="Live pressure = currentTokens / thresholdTokens (0–100%).">Pressure</span><span class="value" id="cf-pressure">—</span>
409
415
  <span class="label">Threshold</span><span class="value" id="cf-threshold">—</span>
410
416
  <span class="label">Fast Gate</span><span class="value" id="cf-gate">—</span>
411
417
  <span class="label">Auto</span><span class="value" id="cf-auto">—</span>
@@ -584,7 +590,12 @@ function dashboardHtml(tierName: string): string {
584
590
  document.getElementById('cr-status').textContent = (crew.activeAgents > 0)
585
591
  ? ('▶ ' + crew.activeAgents + ' running') : 'idle';
586
592
 
587
- document.getElementById('cf-tier').textContent = d.tier;
593
+ // S24: headline tier is the LIVE pressure band; the config card shows the
594
+ // env preset + live pressure ratio so the user sees the system react.
595
+ document.getElementById('hdr-tier').textContent = d.tier;
596
+ document.getElementById('cf-tier').textContent = d.tier + ' (live)';
597
+ document.getElementById('cf-preset').textContent = d.presetTier;
598
+ document.getElementById('cf-pressure').textContent = Math.round((d.pressure || 0) * 100) + '%';
588
599
  document.getElementById('cf-threshold').textContent = d.config.thresholdTokens.toLocaleString();
589
600
  document.getElementById('cf-gate').textContent = d.config.fastGatePct + '%';
590
601
  document.getElementById('cf-auto').textContent = d.config.auto ? 'enabled' : 'disabled';
@@ -14,7 +14,7 @@ import { decompressSmart } from "../src/store/compression.js";
14
14
  import { loadMetrics, fpRate, p95 } from "../src/monitoring.js";
15
15
  import { MegaRuntime, C, recentUserQuery } from "./mega-runtime.js";
16
16
  import { runCompact, doRecall, doRecallAsync } from "./mega-pipeline.js";
17
- import { setTier, COMPACT_TIERS, type MegaConfig, type CompactTier } from "./mega-config.js";
17
+ import { type MegaConfig } from "./mega-config.js";
18
18
 
19
19
  /** Resolve a checkpoint by id (or "recent"/"last") from this session's store. */
20
20
  export function findCheckpoint(runtime: MegaRuntime, sid: string, ref: string) {
@@ -126,7 +126,8 @@ export function registerCommands(pi: ExtensionAPI, runtime: MegaRuntime, config:
126
126
  } catch { /* non-fatal */ }
127
127
  const crossRepoStr = `${crossRepoInjections} cross-repo injections recorded · ${repoCount} repos indexed`;
128
128
  ctx.ui.notify(
129
- `[mega-compact] pct=${pct} tokens=${tokens} tier=${config.tier} fastGate=${config.fastGatePct}% ` +
129
+ `[mega-compact] pct=${pct} tokens=${tokens} tier=${runtime.pressureBand} (live) preset=${config.tier} ` +
130
+ `pressure=${Math.round(runtime.pressure * 100)}% fastGate=${config.fastGatePct}% ` +
130
131
  `threshold=${config.thresholdTokens} auto=${config.auto} autoInline=${config.autoInline}\n` +
131
132
  `[mega-compact] store: ${st.checkpointCount} chkpt · ` +
132
133
  `${st.totalTokenEstimate} tok · last=${st.lastCheckpointId ?? "—"} · ` +
@@ -239,27 +240,7 @@ export function registerCommands(pi: ExtensionAPI, runtime: MegaRuntime, config:
239
240
  },
240
241
  });
241
242
 
242
- pi.registerCommand("mega-tier", {
243
- description: "Show or change the compaction tier at runtime. Usage: /mega-tier [low|medium|high|ultra|mega]",
244
- handler: async (args: string, ctx: ExtensionContext) => {
245
- const arg = args.trim().toLowerCase();
246
- if (!arg) {
247
- // Show current tier and available options.
248
- ctx.ui.notify(
249
- `[mega-compact] current tier: ${config.tier} (${config.thresholdTokens} tok)\n` +
250
- `[mega-compact] available tiers: ${Object.entries(COMPACT_TIERS).map(([k, v]) => `${k}=${v}`).join(", ")}`,
251
- );
252
- return;
253
- }
254
- if (!(arg in COMPACT_TIERS)) {
255
- ctx.ui.notify(`[mega-compact] unknown tier "${arg}". Available: ${Object.keys(COMPACT_TIERS).join(", ")}`);
256
- return;
257
- }
258
- const newTier = arg as CompactTier;
259
- setTier(config, newTier);
260
- runtime.setStatus(ctx, `mega-compact: tier → ${newTier} (${config.thresholdTokens} tok)`);
261
- ctx.ui.notify(`[mega-compact] tier changed to ${newTier} (threshold: ${config.thresholdTokens} tokens)`);
262
- runtime.snapshot(ctx);
263
- },
264
- });
243
+ // NOTE: /mega-tier was removed in S24. The tier the user sees is now the LIVE
244
+ // pressure band (low/medium/high/ultra/mega), which climbs automatically as
245
+ // context fills there is no manual tier to set. See docs/specs/s24-unified-pressure.md.
265
246
  }
@@ -416,19 +416,22 @@ const TIER_CASES: Array<[string, number]> = [
416
416
  ["mega", 10_000_000],
417
417
  ];
418
418
  for (const [tier, threshold] of TIER_CASES) {
419
- test(`tier "${tier}" resolves to a ${threshold}-token threshold`, async () => {
419
+ test(`tier "${tier}" resolves to a ${threshold}-token threshold (preset; live band shown separately)`, async () => {
420
420
  // Keep tier + keep threshold UNSET so the tier (not an explicit number)
421
421
  // drives the threshold. harness() would otherwise reset the threshold.
422
422
  delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
423
423
  process.env.MEGACOMPACT_TIER = tier;
424
424
  const h = harness({ keepTier: true, keepThreshold: true });
425
+ // tokens=1 against a 2M window → near-zero pressure → live band "low".
425
426
  const ctx = h.ctx({ getContextUsage: () => ({ tokens: 1, contextWindow: 2_000_000, percent: 0.01 }) });
426
427
  await h.commands["mega-status"].handler("", ctx);
427
428
  delete process.env.MEGACOMPACT_TIER;
428
429
  assert.ok(
429
- h.notifies.some((n) => n.includes(`tier=${tier}`) && n.includes(`threshold=${threshold}`)),
430
- `status should report tier=${tier} threshold=${threshold}`,
430
+ h.notifies.some((n) => n.includes(`preset=${tier}`) && n.includes(`threshold=${threshold}`)),
431
+ `status should report preset=${tier} threshold=${threshold}`,
431
432
  );
433
+ // S24: the headline tier is the LIVE pressure band, shown as "tier=low (live)".
434
+ assert.ok(h.notifies.some((n) => n.includes("tier=low (live)")), "live band reported (low at near-zero pressure)");
432
435
  });
433
436
  }
434
437
 
@@ -440,8 +443,8 @@ test("explicit MEGACOMPACT_THRESHOLD_TOKENS overrides the tier", async () => {
440
443
  await h.commands["mega-status"].handler("", ctx);
441
444
  delete process.env.MEGACOMPACT_TIER;
442
445
  assert.ok(
443
- h.notifies.some((n) => n.includes("tier=custom") && n.includes("threshold=777")),
444
- "explicit threshold wins over tier (tier=custom)",
446
+ h.notifies.some((n) => n.includes("preset=custom") && n.includes("threshold=777")),
447
+ "explicit threshold wins over tier (preset=custom)",
445
448
  );
446
449
  });
447
450
 
@@ -25,8 +25,13 @@ export const COMPACT_TIERS = {
25
25
  } as const;
26
26
  export type CompactTier = keyof typeof COMPACT_TIERS;
27
27
 
28
- /** Resolved, frozen-at-load config. tier/thresholdTokens are mutated at
29
- * runtime by /mega-tier via `setTier`. */
28
+ /**
29
+ * Resolved, frozen-at-load config. `tier` is the base compaction PRESET chosen
30
+ * by env (low/medium/high/ultra/mega) — it sets the threshold token budget and
31
+ * is NOT changed at runtime (the /mega-tier command was removed in S24). The
32
+ * *displayed* tier the user sees in the toolbar/dashboard is the LIVE pressure
33
+ * band (see MegaRuntime.pressureBand), which climbs low→mega as context fills.
34
+ */
30
35
  export interface MegaConfig {
31
36
  tier: CompactTier | "custom";
32
37
  thresholdTokens: number;
@@ -97,11 +102,20 @@ function resolveThreshold(): { tier: CompactTier | "custom"; thresholdTokens: nu
97
102
  }
98
103
 
99
104
  /**
100
- * Pressure helpers for adaptive compression (Fix E) live in src/config.ts
101
- * (pi-agnostic) so unit tests can import them without the pi runtime. Re-export
102
- * here so the extension has one import surface.
105
+ * Pressure helpers for adaptive compression live in src/config.ts (pi-agnostic)
106
+ * so unit tests can import them without the pi runtime. Re-export here so the
107
+ * extension has one import surface. (S24 unified the previously percentage-only
108
+ * signal into pressureRatio/pressureBand, which the runtime uses as the single
109
+ * "how full" signal that drives the tier label, trim depth, and memory cadence.)
103
110
  */
104
- export { pressureFromPct, preserveRecentForPressure } from "../src/config.js";
111
+ export {
112
+ pressureFromPct,
113
+ preserveRecentForPressure,
114
+ pressureRatio,
115
+ pressureBand,
116
+ memoryReviewCadence,
117
+ type PressureBand,
118
+ } from "../src/config.js";
105
119
 
106
120
  /** Build the resolved config from env + defaults. */
107
121
  export function loadConfig(): MegaConfig {
@@ -132,11 +146,12 @@ export function loadConfig(): MegaConfig {
132
146
  };
133
147
  }
134
148
 
135
- /** Mutate tier + threshold in place (used by /mega-tier at runtime). */
136
- export function setTier(config: MegaConfig, tier: CompactTier): void {
137
- config.tier = tier;
138
- config.thresholdTokens = COMPACT_TIERS[tier];
139
- }
149
+ /**
150
+ * Remove a cached tier mutation helper here — the live tier the user sees is the
151
+ * pressure band (MegaRuntime.pressureBand), and the base preset is env-resolved
152
+ * at load (loadConfig). The /mega-tier command was removed in S24 so there is no
153
+ * runtime tier mutation; see the S24 spec (docs/specs/s24-unified-pressure.md).
154
+ */
140
155
 
141
156
  /**
142
157
  * Resolve the current repo's git root from a cwd. Returns undefined for a
@@ -19,7 +19,12 @@ import { existsSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs";
19
19
  export interface DashboardSnapshot {
20
20
  version: 1;
21
21
  updatedAt: string;
22
+ /** Live pressure band (low/medium/high/ultra/mega) — climbs as context fills. */
22
23
  tier: string;
24
+ /** Base compaction preset from env (the S24-removed /mega-tier style selector). */
25
+ presetTier: string;
26
+ /** Live 0–1 pressure ratio (currentTokens / thresholdTokens). */
27
+ pressure: number;
23
28
  config: {
24
29
  fastGatePct: number;
25
30
  thresholdTokens: number;
@@ -17,7 +17,7 @@ import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop } from "./mega-
17
17
  import { recallMemoriesAndInline } from "../src/recall.js";
18
18
  import { driveNativeCompaction } from "./mega-compact-driver.js";
19
19
  import { computeLiveTrimCut, liveTrimSummaryMessage } from "./mega-trim.js";
20
- import { pressureFromPct, type MegaConfig } from "./mega-config.js";
20
+ import { pressureFromPct, memoryReviewCadence, type MegaConfig } from "./mega-config.js";
21
21
 
22
22
  /** Register all pi lifecycle event handlers. */
23
23
  export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, config: MegaConfig): void {
@@ -164,25 +164,30 @@ export function registerEventHandlers(pi: ExtensionAPI, runtime: MegaRuntime, co
164
164
  runtime.dashboard.event("turn_end", { turnIndex: event.turnIndex });
165
165
  runtime.snapshot(ctx);
166
166
 
167
- // S20: auto-review the conversation every N turns and persist durable
168
- // memories. Best-effort + non-fatal: a review failure must never break the
169
- // agent loop. Debounced by memoryReviewInterval turns.
170
- if (config.memoryAutoReview && runtime.currentTurn > 0 && runtime.currentTurn % config.memoryReviewInterval === 0) {
171
- try {
172
- const { reviewConversation } = await import("../src/memory.js");
173
- const { applyMemoryOps } = await import("../src/memoryOps.js");
174
- const entries = ctx.sessionManager.getEntries();
175
- const view = runtime.engineView(entries.flatMap((e: any) => (e.message ? [e.message] : [])));
176
- const ops = reviewConversation(view, []);
177
- if (ops.length) {
178
- await applyMemoryOps(ops, runtime.currentStateDir);
179
- // S21.2: a memory op landed in this turn window. The pipeline reads
180
- // this counter after a successful compaction and fires
181
- // `consolidateMemories` only when it's > 0.
182
- runtime.memoriesTouchedThisCompaction += ops.length;
167
+ // S20+S24: auto-review the conversation and persist durable memories. The
168
+ // review cadence scales with pressure (memoryReviewCadence): as context
169
+ // fills, the conversation is reviewed more often so memories keep pace with
170
+ // faster churn. Best-effort + non-fatal: a review failure must never break
171
+ // the agent loop. Debounced by the pressure-adjusted interval.
172
+ if (config.memoryAutoReview && runtime.currentTurn > 0) {
173
+ const cadence = memoryReviewCadence(runtime.pressureBand, config.memoryReviewInterval);
174
+ if (runtime.currentTurn % cadence === 0) {
175
+ try {
176
+ const { reviewConversation } = await import("../src/memory.js");
177
+ const { applyMemoryOps } = await import("../src/memoryOps.js");
178
+ const entries = ctx.sessionManager.getEntries();
179
+ const view = runtime.engineView(entries.flatMap((e: any) => (e.message ? [e.message] : [])));
180
+ const ops = reviewConversation(view, []);
181
+ if (ops.length) {
182
+ await applyMemoryOps(ops, runtime.currentStateDir);
183
+ // S21.2: a memory op landed in this turn window. The pipeline reads
184
+ // this counter after a successful compaction and fires
185
+ // `consolidateMemories` only when it's > 0.
186
+ runtime.memoriesTouchedThisCompaction += ops.length;
187
+ }
188
+ } catch {
189
+ /* non-fatal — auto-review must not break the turn loop */
183
190
  }
184
- } catch {
185
- /* non-fatal — auto-review must not break the turn loop */
186
191
  }
187
192
  }
188
193
  });
@@ -175,6 +175,28 @@ function doCompact(
175
175
  }
176
176
  }
177
177
 
178
+ // S24 review-on-compact: when pressure is high, the just-compacted region is
179
+ // exactly the context worth remembering, so review it immediately rather than
180
+ // waiting for the next turn-cadence tick. Fire-and-forget (doCompact is sync):
181
+ // best-effort + non-fatal, paralleling the consolidate pass above. Only fires
182
+ // above the `high` band so low-pressure compactions don't pay the review cost.
183
+ if (!result.deduped && config.memoryAutoReview && runtime.pressureBand !== "low" && runtime.pressureBand !== "medium") {
184
+ void (async () => {
185
+ try {
186
+ const { reviewConversation } = await import("../src/memory.js");
187
+ const { applyMemoryOps } = await import("../src/memoryOps.js");
188
+ const ops = reviewConversation(view, []);
189
+ if (ops.length) {
190
+ await applyMemoryOps(ops, runtime.currentStateDir);
191
+ runtime.memoriesTouchedThisCompaction += ops.length;
192
+ runtime.pushTicker(`${C.green}🧠${C.reset} reviewed ${ops.length} memory op${ops.length === 1 ? "" : "s"} (pressure)`);
193
+ }
194
+ } catch {
195
+ /* non-fatal — review-on-compact must never break the compaction */
196
+ }
197
+ })();
198
+ }
199
+
178
200
  // Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
179
201
  // skip re-vectorizing an already-compacted region (zero token cost).
180
202
  pi.appendEntry(MARKER_TYPE, {
@@ -20,7 +20,7 @@ import { toEngineMessages } from "../src/adapt.js";
20
20
  import { normalizeSessionId } from "../src/store.js";
21
21
  import { Logger } from "../src/log.js";
22
22
  import { recordModelSnapshot, latestModelSnapshot, upsertRepoRegistry, recordRepoModel, type ModelSnapshot } from "../src/store/sqlite.js";
23
- import { repoStateDir, resolveRepoRoot, type MegaConfig } from "./mega-config.js";
23
+ import { repoStateDir, resolveRepoRoot, pressureRatio, pressureFromPct, pressureBand, type MegaConfig, type PressureBand } from "./mega-config.js";
24
24
  import { Dashboard, type DashboardSnapshot } from "./mega-dashboard.js";
25
25
 
26
26
  export const STATUS_KEY = "mega-compact";
@@ -143,6 +143,26 @@ export class MegaRuntime {
143
143
  lastCtxPercent: number | null = null;
144
144
  lastCtxWindow = 0;
145
145
 
146
+ /**
147
+ * Live 0–1 pressure: how full the context window is relative to the compaction
148
+ * threshold. Computed from the most recent context event the runtime already
149
+ * tracks (token count when available — the direct signal — otherwise the usage
150
+ * percentage). This is the single "how full" number every subsystem reads; the
151
+ * toolbar/dashboard tier label is `pressureBand` over this, so it climbs
152
+ * low→mega as context rises (S24). Always finite + in [0,1].
153
+ */
154
+ get pressure(): number {
155
+ if (this.lastCtxTokens != null && this.lastCtxTokens > 0 && this.config.thresholdTokens > 0) {
156
+ return pressureRatio(this.lastCtxTokens, this.config.thresholdTokens);
157
+ }
158
+ return pressureFromPct(this.lastCtxPercent);
159
+ }
160
+
161
+ /** Live discrete pressure band (low/medium/high/ultra/mega) over `pressure`. */
162
+ get pressureBand(): PressureBand {
163
+ return pressureBand(this.pressure);
164
+ }
165
+
146
166
  constructor(config: MegaConfig) {
147
167
  this.config = config;
148
168
  this.store = new VectorStore({ dedupSim: config.dedupSim, stateDir: config.stateDir });
@@ -214,7 +234,11 @@ export class MegaRuntime {
214
234
  this.dashboard.snapshot({
215
235
  version: 1,
216
236
  updatedAt: new Date().toISOString(),
217
- tier: this.config.tier,
237
+ // S24: the headline tier is the LIVE pressure band; the env preset is kept
238
+ // alongside as presetTier so the dashboard can show both.
239
+ tier: this.pressureBand,
240
+ presetTier: this.config.tier,
241
+ pressure: this.pressure,
218
242
  config: {
219
243
  fastGatePct: this.config.fastGatePct,
220
244
  thresholdTokens: this.config.thresholdTokens,
@@ -261,6 +285,11 @@ export class MegaRuntime {
261
285
  const tokStr = this.lastCtxTokens != null ? `${Math.round(this.lastCtxTokens / 1000)}k` : "?";
262
286
  const maxStr = this.lastCtxWindow > 0 ? `${Math.round(this.lastCtxWindow / 1000)}k` : "?";
263
287
  const pctStr = this.lastCtxPercent != null ? `${Math.round(this.lastCtxPercent * 10) / 10}%` : "?%";
288
+ // S24: the tier label is the LIVE pressure band (low/medium/high/ultra/
289
+ // mega), not the static env preset. It climbs as context fills, so the
290
+ // user can see the system react. The base preset is shown as a dim suffix.
291
+ const liveBand = this.pressureBand;
292
+ const tierLabel = `${C.bold}${liveBand}${C.reset}${C.gray}·${this.config.tier}${C.reset}`;
264
293
  const triggerLabel = ready ? `${C.green}● ready${C.reset}` : armed ? `${C.amber}◐ armed${C.reset}` : `${C.gray}○ idle${C.reset}`;
265
294
  // Storage dedup rate is cumulative (store-wide, per-repo) and survives
266
295
  // session resets. Always show a number: 0% before any compaction, a
@@ -283,7 +312,7 @@ export class MegaRuntime {
283
312
  // Phase 3 — pulsing status glyph while a compaction is in flight.
284
313
  const pulse = this.pulsing ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} ` : "";
285
314
  const lines = [
286
- ` ${C.amber}⚡ ${this.config.tier}${C.reset} v${C.bold}${ownVersion()}${C.reset} │ ${tokStr}/${maxStr} tokens (${C.bold}${pctStr}${C.reset}) │ ${st.checkpointCount} saved${agentStr}${turnStr}`,
315
+ ` ${C.amber}⚡ ${tierLabel}${C.reset} v${C.bold}${ownVersion()}${C.reset} │ ${tokStr}/${maxStr} tokens (${C.bold}${pctStr}${C.reset}) │ ${st.checkpointCount} saved${agentStr}${turnStr}`,
287
316
  ` ${triggerLabel} │ ${C.magenta}repeat-skipped: ${dedupStr}${C.reset} │ ${C.gray}memory held:${C.reset} ${usedStr} │ ${C.gray}space freed:${C.reset} ${savedStr}`,
288
317
  ];
289
318
  // Phase 3 — compact progress bar: session tokens saved toward the rolling goal.
@@ -292,7 +321,10 @@ export class MegaRuntime {
292
321
  const pct = Math.min(100, Math.round((this.rt.tokensSaved / goal) * 100));
293
322
  const filled = Math.round((pct / 100) * 10);
294
323
  const bar = "▓".repeat(filled) + "░".repeat(10 - filled);
295
- lines.push(` ${C.green}saved ${fmt(this.rt.tokensSaved)} ${bar}${C.reset} ${pct}% of ${fmt(goal)}`);
324
+ // Session tokens saved, with the repo-wide total held alongside so the
325
+ // bar reads "saved X of goal" and the right side shows saved vs total.
326
+ const totalHeld = st.totalTokenEstimate > 0 ? st.totalTokenEstimate : repo.totalTokenEstimate;
327
+ lines.push(` ${C.green}saved ${fmt(this.rt.tokensSaved)} ${bar}${C.reset} ${pct}% of ${fmt(goal)} ${C.gray}│${C.reset} ${C.blue}${fmt(this.rt.tokensSaved)}${C.reset}/${C.blue}${fmt(totalHeld)}${C.reset} tok held`);
296
328
  }
297
329
  // Live "now processing" line + why + recent deduped/compacted events,
298
330
  // collapsed to ONE rotating line (fresh only). The ticker ring buffer
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
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",