pi-mega-compact 0.5.2 → 0.6.1

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
@@ -6,7 +6,7 @@ sessions into a **local SQLite store** and offers **deduped inline recall** —
6
6
  running **locally inside the extension**, with **no remote MCP server** and
7
7
  **zero network calls at runtime** (PREVENT-PI-004).
8
8
 
9
- > **Current version:** `v0.5.1` — storage backend is **`node:sqlite`**
9
+ > **Current version:** `v0.6.1` — storage backend is **`node:sqlite`**
10
10
  > (`DatabaseSync`, a Node ≥22.13 built-in), replacing the old `better-sqlite3`
11
11
  > native addon and the per-session gzipped JSON checkpoint files. **Zero native
12
12
  > build step, fully local, zero network at runtime.** Legacy
@@ -214,9 +214,10 @@ The commands (slash commands inside pi):
214
214
  | `/mega-restore <chkpt\|recent>` | Re-inject a checkpoint's verbatim original region into context. |
215
215
  | `/mega-history` | List this session's checkpoints (id, date, files, tokens). |
216
216
  | `/mega-view <chkpt\|recent>` | Show a checkpoint's verbatim original region. |
217
- | `/mega-help` | Explain the toolbar widget terms (tier, gate, dedup, tokens saved). |
218
- | `/mega-tier [name]` | Set the compaction tier (`low` / `medium` / `high` / `ultra` / `mega`). Shows current tier with no arg. |
217
+ | `/mega-help` | Explain the toolbar widget terms (live tier, gate, dedup, tokens saved). |
219
218
  | `/mega-compat-check` | Detect extension conflicts (duplicate commands / overlapping handlers) across installed pi extensions. |
219
+
220
+ The **tier** you see in the toolbar and dashboard is a *live pressure band* (`low` → `medium` → `high` → `ultra` → `mega`) that climbs automatically as your context window fills and falls back as it's relieved — it is driven by `currentTokens / thresholdTokens`, not a manual setting. The base compaction *threshold* (token budget) is still chosen by the `MEGACOMPACT_TIER` env var at startup (`low`/`medium`/`high`/`ultra`/`mega`, default `low`); `/mega-tier` was removed in v0.6.0. Higher pressure also deepens the live trim and reviews durable memory more often — the whole system reacts as one.
220
221
  | `/mega-dashboard` | Start the **localhost-only** live dashboard and open it in a browser (token gauge, store stats, live event stream, per-repo + cross-repo drift). |
221
222
  | `/mega-dashboard-status` | Report dashboard server status. |
222
223
  | `/mega-dashboard-stop` | Stop the dashboard server. |
@@ -226,7 +227,7 @@ The commands (slash commands inside pi):
226
227
  Above the pi editor the extension shows a compact widget:
227
228
 
228
229
  ```
229
- medium v0.5.1 │ 142k/200k tokens (71%) │ 3 chkpts │ 🤖 2 agents │ turn 5
230
+ high·low v0.6.0 │ 142k/200k tokens (71%) │ 3 chkpts │ 🤖 2 agents │ turn 5
230
231
  ◐ armed │ dedup: 92% │ saved: 45k tok
231
232
  ```
232
233
 
@@ -136,6 +136,8 @@ function readSnapshot(snapshotPath) {
136
136
  version: 1,
137
137
  updatedAt: null,
138
138
  tier: "unknown",
139
+ presetTier: "unknown",
140
+ pressure: 0,
139
141
  config: { fastGatePct: 80, thresholdTokens: 100_000, anchorUserMessages: 1, preserveRecent: 2, auto: true, autoInlineK: 3 },
140
142
  session: { id: null, state: null, persistedThisSession: false, lastCheckpointId: null, lastCompactedFrom: 0 },
141
143
  context: { tokens: null, percent: null, contextWindow: 0 },
@@ -261,7 +263,7 @@ function dashboardHtml(tierName) {
261
263
 
262
264
  <div class="offline-banner" id="offline-banner">Dashboard data unavailable — waiting for a pi session to write snapshot...</div>
263
265
 
264
- <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>
266
+ <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>
265
267
 
266
268
  <nav class="tabs">
267
269
  <button class="tab active" data-tab="current">Current repo</button>
@@ -323,7 +325,9 @@ function dashboardHtml(tierName) {
323
325
  <div class="card">
324
326
  <h2>Configuration</h2>
325
327
  <div class="conf-grid">
326
- <span class="label">Tier</span><span class="value" id="cf-tier">${tierName}</span>
328
+ <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>
329
+ <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>
330
+ <span class="label" title="Live pressure = currentTokens / thresholdTokens (0–100%).">Pressure</span><span class="value" id="cf-pressure">—</span>
327
331
  <span class="label">Threshold</span><span class="value" id="cf-threshold">—</span>
328
332
  <span class="label">Fast Gate</span><span class="value" id="cf-gate">—</span>
329
333
  <span class="label">Auto</span><span class="value" id="cf-auto">—</span>
@@ -502,7 +506,12 @@ function dashboardHtml(tierName) {
502
506
  document.getElementById('cr-status').textContent = (crew.activeAgents > 0)
503
507
  ? ('▶ ' + crew.activeAgents + ' running') : 'idle';
504
508
 
505
- document.getElementById('cf-tier').textContent = d.tier;
509
+ // S24: headline tier is the LIVE pressure band; the config card shows the
510
+ // env preset + live pressure ratio so the user sees the system react.
511
+ document.getElementById('hdr-tier').textContent = d.tier;
512
+ document.getElementById('cf-tier').textContent = d.tier + ' (live)';
513
+ document.getElementById('cf-preset').textContent = d.presetTier;
514
+ document.getElementById('cf-pressure').textContent = Math.round((d.pressure || 0) * 100) + '%';
506
515
  document.getElementById('cf-threshold').textContent = d.config.thresholdTokens.toLocaleString();
507
516
  document.getElementById('cf-gate').textContent = d.config.fastGatePct + '%';
508
517
  document.getElementById('cf-auto').textContent = d.config.auto ? 'enabled' : 'disabled';
@@ -12,7 +12,6 @@ import { decompressSmart } from "../src/store/compression.js";
12
12
  import { loadMetrics, fpRate, p95 } from "../src/monitoring.js";
13
13
  import { C, recentUserQuery } from "./mega-runtime.js";
14
14
  import { runCompact, doRecall, doRecallAsync } from "./mega-pipeline.js";
15
- import { setTier, COMPACT_TIERS } from "./mega-config.js";
16
15
  /** Resolve a checkpoint by id (or "recent"/"last") from this session's store. */
17
16
  export function findCheckpoint(runtime, sid, ref) {
18
17
  const all = listCheckpoints(sid, runtime.currentStateDir);
@@ -117,7 +116,8 @@ export function registerCommands(pi, runtime, config) {
117
116
  }
118
117
  catch { /* non-fatal */ }
119
118
  const crossRepoStr = `${crossRepoInjections} cross-repo injections recorded · ${repoCount} repos indexed`;
120
- ctx.ui.notify(`[mega-compact] pct=${pct} tokens=${tokens} tier=${config.tier} fastGate=${config.fastGatePct}% ` +
119
+ ctx.ui.notify(`[mega-compact] pct=${pct} tokens=${tokens} tier=${runtime.pressureBand} (live) preset=${config.tier} ` +
120
+ `pressure=${Math.round(runtime.pressure * 100)}% fastGate=${config.fastGatePct}% ` +
121
121
  `threshold=${config.thresholdTokens} auto=${config.auto} autoInline=${config.autoInline}\n` +
122
122
  `[mega-compact] store: ${st.checkpointCount} chkpt · ` +
123
123
  `${st.totalTokenEstimate} tok · last=${st.lastCheckpointId ?? "—"} · ` +
@@ -215,25 +215,7 @@ export function registerCommands(pi, runtime, config) {
215
215
  `• data safety — every compressed region is kept verbatim; nothing is permanently deleted. /mega-restore brings any of it back.`);
216
216
  },
217
217
  });
218
- pi.registerCommand("mega-tier", {
219
- description: "Show or change the compaction tier at runtime. Usage: /mega-tier [low|medium|high|ultra|mega]",
220
- handler: async (args, ctx) => {
221
- const arg = args.trim().toLowerCase();
222
- if (!arg) {
223
- // Show current tier and available options.
224
- ctx.ui.notify(`[mega-compact] current tier: ${config.tier} (${config.thresholdTokens} tok)\n` +
225
- `[mega-compact] available tiers: ${Object.entries(COMPACT_TIERS).map(([k, v]) => `${k}=${v}`).join(", ")}`);
226
- return;
227
- }
228
- if (!(arg in COMPACT_TIERS)) {
229
- ctx.ui.notify(`[mega-compact] unknown tier "${arg}". Available: ${Object.keys(COMPACT_TIERS).join(", ")}`);
230
- return;
231
- }
232
- const newTier = arg;
233
- setTier(config, newTier);
234
- runtime.setStatus(ctx, `mega-compact: tier → ${newTier} (${config.thresholdTokens} tok)`);
235
- ctx.ui.notify(`[mega-compact] tier changed to ${newTier} (threshold: ${config.thresholdTokens} tokens)`);
236
- runtime.snapshot(ctx);
237
- },
238
- });
218
+ // NOTE: /mega-tier was removed in S24. The tier the user sees is now the LIVE
219
+ // pressure band (low/medium/high/ultra/mega), which climbs automatically as
220
+ // context fills there is no manual tier to set. See docs/specs/s24-unified-pressure.md.
239
221
  }
@@ -384,16 +384,19 @@ const TIER_CASES = [
384
384
  ["mega", 10_000_000],
385
385
  ];
386
386
  for (const [tier, threshold] of TIER_CASES) {
387
- test(`tier "${tier}" resolves to a ${threshold}-token threshold`, async () => {
387
+ test(`tier "${tier}" resolves to a ${threshold}-token threshold (preset; live band shown separately)`, async () => {
388
388
  // Keep tier + keep threshold UNSET so the tier (not an explicit number)
389
389
  // drives the threshold. harness() would otherwise reset the threshold.
390
390
  delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
391
391
  process.env.MEGACOMPACT_TIER = tier;
392
392
  const h = harness({ keepTier: true, keepThreshold: true });
393
+ // tokens=1 against a 2M window → near-zero pressure → live band "low".
393
394
  const ctx = h.ctx({ getContextUsage: () => ({ tokens: 1, contextWindow: 2_000_000, percent: 0.01 }) });
394
395
  await h.commands["mega-status"].handler("", ctx);
395
396
  delete process.env.MEGACOMPACT_TIER;
396
- assert.ok(h.notifies.some((n) => n.includes(`tier=${tier}`) && n.includes(`threshold=${threshold}`)), `status should report tier=${tier} threshold=${threshold}`);
397
+ assert.ok(h.notifies.some((n) => n.includes(`preset=${tier}`) && n.includes(`threshold=${threshold}`)), `status should report preset=${tier} threshold=${threshold}`);
398
+ // S24: the headline tier is the LIVE pressure band, shown as "tier=low (live)".
399
+ assert.ok(h.notifies.some((n) => n.includes("tier=low (live)")), "live band reported (low at near-zero pressure)");
397
400
  });
398
401
  }
399
402
  test("explicit MEGACOMPACT_THRESHOLD_TOKENS overrides the tier", async () => {
@@ -403,7 +406,55 @@ test("explicit MEGACOMPACT_THRESHOLD_TOKENS overrides the tier", async () => {
403
406
  const ctx = h.ctx({ getContextUsage: () => ({ tokens: 1, contextWindow: 2_000_000, percent: 0.01 }) });
404
407
  await h.commands["mega-status"].handler("", ctx);
405
408
  delete process.env.MEGACOMPACT_TIER;
406
- assert.ok(h.notifies.some((n) => n.includes("tier=custom") && n.includes("threshold=777")), "explicit threshold wins over tier (tier=custom)");
409
+ assert.ok(h.notifies.some((n) => n.includes("preset=custom") && n.includes("threshold=777")), "explicit threshold wins over tier (preset=custom)");
410
+ });
411
+ // ---- S24: memory review tied to pressure / compaction -----------------------
412
+ // Build a decision-bearing session large enough to guarantee a real (non-skipped,
413
+ // non-deduped) compaction. Each user turn contains a decision phrase
414
+ // (/\bactually\b/i, /\bwe (?:use|decided)\b/i) so reviewConversation yields ops.
415
+ function decisionSession() {
416
+ const out = [];
417
+ for (let i = 0; i < 14; i++) {
418
+ out.push({ role: "user", content: `actually we decided to use approach ${i} for module ${i}`, timestamp: i });
419
+ out.push({ role: "assistant", content: [{ type: "toolCall", name: "Edit", id: `c${i}`, arguments: {} }], api: "anthropic-messages", provider: "anthropic", model: "m", usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, stopReason: "tool_use", timestamp: i });
420
+ out.push({ role: "toolResult", content: [{ type: "text", text: `edited module ${i}` }], toolCallId: `c${i}`, toolName: "Edit", isError: false, timestamp: i });
421
+ }
422
+ return out;
423
+ }
424
+ test("S24: high pressure triggers a memory review on compaction", async () => {
425
+ const h = harness();
426
+ // Force a real (non-legacy) compaction at full pressure → pressureBand "mega",
427
+ // which must fire the shared runMemoryReview on compact (review-on-compact).
428
+ process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM = "false";
429
+ try {
430
+ const messages = decisionSession();
431
+ const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
432
+ await h.fire("context", { type: "context", messages }, ctx);
433
+ // review-on-compact runs as a fire-and-forget async (doCompact is sync), so
434
+ // let the microtask/macrotask queue drain before asserting the side effect.
435
+ await new Promise((r) => setTimeout(r, 20));
436
+ const { listMemories, listCheckpoints } = await import("../src/store/sqlite.js");
437
+ // A checkpoint must have been persisted (proves compaction ran, not skipped).
438
+ assert.ok(listCheckpoints("sess_ext_001", h.stateDir).length > 0, "checkpoint persisted to local vector db");
439
+ // The just-compacted region is worth remembering, so durable memories must
440
+ // have been written to the SQLite store (review-on-compact path).
441
+ const mem = listMemories(null, 50, h.stateDir);
442
+ assert.ok(mem.length > 0, "memory review wrote durable memories on compact");
443
+ }
444
+ finally {
445
+ delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
446
+ }
447
+ });
448
+ test("S24: /mega-status reports the live pressure band + %", async () => {
449
+ const h = harness();
450
+ // Populate the runtime's live context first (a context event sets
451
+ // lastCtxTokens/lastCtxPercent), then read /mega-status. At 100% usage the live
452
+ // band must read "mega" and pressure must report 100%.
453
+ const ctx = h.ctx({ getContextUsage: () => ({ tokens: 200000, contextWindow: 200000, percent: 100 }) });
454
+ await h.fire("context", { type: "context", messages: h.session }, ctx);
455
+ await h.commands["mega-status"].handler("", ctx);
456
+ assert.ok(h.notifies.some((n) => n.includes("tier=mega (live)")), "live band reported as mega at 100% pressure");
457
+ assert.ok(h.notifies.some((n) => n.includes("pressure=100%")), "live pressure % reported");
407
458
  });
408
459
  // ---- /dashboard commands ----------------------------------------------------
409
460
  test("/dashboard-status reports no server when pid file missing", async () => {
@@ -47,11 +47,13 @@ function resolveThreshold() {
47
47
  return { tier, thresholdTokens: COMPACT_TIERS[tier] };
48
48
  }
49
49
  /**
50
- * Pressure helpers for adaptive compression (Fix E) live in src/config.ts
51
- * (pi-agnostic) so unit tests can import them without the pi runtime. Re-export
52
- * here so the extension has one import surface.
50
+ * Pressure helpers for adaptive compression live in src/config.ts (pi-agnostic)
51
+ * so unit tests can import them without the pi runtime. Re-export here so the
52
+ * extension has one import surface. (S24 unified the previously percentage-only
53
+ * signal into pressureRatio/pressureBand, which the runtime uses as the single
54
+ * "how full" signal that drives the tier label, trim depth, and memory cadence.)
53
55
  */
54
- export { pressureFromPct, preserveRecentForPressure } from "../src/config.js";
56
+ export { pressureFromPct, preserveRecentForPressure, pressureRatio, pressureBand, memoryReviewCadence, } from "../src/config.js";
55
57
  /** Build the resolved config from env + defaults. */
56
58
  export function loadConfig() {
57
59
  const { tier, thresholdTokens } = resolveThreshold();
@@ -80,11 +82,12 @@ export function loadConfig() {
80
82
  debug: envBool("MEGACOMPACT_DEBUG", false),
81
83
  };
82
84
  }
83
- /** Mutate tier + threshold in place (used by /mega-tier at runtime). */
84
- export function setTier(config, tier) {
85
- config.tier = tier;
86
- config.thresholdTokens = COMPACT_TIERS[tier];
87
- }
85
+ /**
86
+ * Remove a cached tier mutation helper here — the live tier the user sees is the
87
+ * pressure band (MegaRuntime.pressureBand), and the base preset is env-resolved
88
+ * at load (loadConfig). The /mega-tier command was removed in S24 so there is no
89
+ * runtime tier mutation; see the S24 spec (docs/specs/s24-unified-pressure.md).
90
+ */
88
91
  /**
89
92
  * Resolve the current repo's git root from a cwd. Returns undefined for a
90
93
  * non-git directory (caller falls back to a global state dir).
@@ -10,11 +10,11 @@ import { normalizeSessionId } from "../src/store.js";
10
10
  import { autoCompactCheck } from "../src/compact.js";
11
11
  import { estimateSessionTokens } from "../src/tokens.js";
12
12
  import { recentUserQuery, WIDGET_KEY } from "./mega-runtime.js";
13
- import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop } from "./mega-pipeline.js";
13
+ import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop, runMemoryReview } from "./mega-pipeline.js";
14
14
  import { recallMemoriesAndInline } from "../src/recall.js";
15
15
  import { driveNativeCompaction } from "./mega-compact-driver.js";
16
16
  import { computeLiveTrimCut, liveTrimSummaryMessage } from "./mega-trim.js";
17
- import { pressureFromPct } from "./mega-config.js";
17
+ import { pressureFromPct, memoryReviewCadence } from "./mega-config.js";
18
18
  /** Register all pi lifecycle event handlers. */
19
19
  export function registerEventHandlers(pi, runtime, config) {
20
20
  // ---- Session lifecycle (state reset points) -------------------------------
@@ -158,26 +158,21 @@ export function registerEventHandlers(pi, runtime, config) {
158
158
  pi.on("turn_end", async (event, ctx) => {
159
159
  runtime.dashboard.event("turn_end", { turnIndex: event.turnIndex });
160
160
  runtime.snapshot(ctx);
161
- // S20: auto-review the conversation every N turns and persist durable
162
- // memories. Best-effort + non-fatal: a review failure must never break the
163
- // agent loop. Debounced by memoryReviewInterval turns.
164
- if (config.memoryAutoReview && runtime.currentTurn > 0 && runtime.currentTurn % config.memoryReviewInterval === 0) {
165
- try {
166
- const { reviewConversation } = await import("../src/memory.js");
167
- const { applyMemoryOps } = await import("../src/memoryOps.js");
161
+ // S20+S24: auto-review the conversation and persist durable memories. The
162
+ // review cadence scales with pressure (memoryReviewCadence): as context
163
+ // fills, the conversation is reviewed more often so memories keep pace with
164
+ // faster churn. Best-effort + non-fatal: a review failure must never break
165
+ // the agent loop. Debounced by the pressure-adjusted interval.
166
+ if (config.memoryAutoReview && runtime.currentTurn > 0) {
167
+ const cadence = memoryReviewCadence(runtime.pressureBand, config.memoryReviewInterval);
168
+ if (runtime.currentTurn % cadence === 0) {
169
+ // S20+S24: review the conversation and persist durable memories. The
170
+ // cadence scales with pressure (memoryReviewCadence): as context fills,
171
+ // the conversation is reviewed more often so memories keep pace with
172
+ // faster churn. Shared runMemoryReview body (also used on compact).
168
173
  const entries = ctx.sessionManager.getEntries();
169
174
  const view = runtime.engineView(entries.flatMap((e) => (e.message ? [e.message] : [])));
170
- const ops = reviewConversation(view, []);
171
- if (ops.length) {
172
- await applyMemoryOps(ops, runtime.currentStateDir);
173
- // S21.2: a memory op landed in this turn window. The pipeline reads
174
- // this counter after a successful compaction and fires
175
- // `consolidateMemories` only when it's > 0.
176
- runtime.memoriesTouchedThisCompaction += ops.length;
177
- }
178
- }
179
- catch {
180
- /* non-fatal — auto-review must not break the turn loop */
175
+ await runMemoryReview(runtime, view, "turn");
181
176
  }
182
177
  }
183
178
  });
@@ -18,6 +18,35 @@ import { resolveRepoRoot, preserveRecentForPressure } from "./mega-config.js";
18
18
  import { runRaptor } from "../src/dedup/raptor/index.js";
19
19
  import { loadDedupConfig } from "../src/config/dedup.js";
20
20
  import { upsertEmbedding as indexUpsertEmbedding } from "../src/store/vectorIndex.js";
21
+ /**
22
+ * Review the live conversation and persist durable memories (S20+S24). Shared by
23
+ * the pressure-scaled turn-end cadence (mega-events.ts) AND review-on-compact
24
+ * (below) so both paths run the identical review body. Best-effort + non-fatal:
25
+ * a review failure is swallowed and never breaks the caller. On success, the
26
+ * number of applied ops is returned so callers can feed the consolidation gate.
27
+ *
28
+ * @param view the engine message view to review (caller builds it)
29
+ * @param label a short source tag for the ticker line (e.g. "pressure" / "turn")
30
+ */
31
+ export async function runMemoryReview(runtime, view, label) {
32
+ try {
33
+ const { reviewConversation } = await import("../src/memory.js");
34
+ const { applyMemoryOps } = await import("../src/memoryOps.js");
35
+ const ops = reviewConversation(view, []);
36
+ if (ops.length) {
37
+ await applyMemoryOps(ops, runtime.currentStateDir);
38
+ // S21.2: ops landed — the compaction path reads this counter and fires
39
+ // `consolidateMemories` only when > 0.
40
+ runtime.memoriesTouchedThisCompaction += ops.length;
41
+ runtime.pushTicker(`${C.green}🧠${C.reset} reviewed ${ops.length} memory op${ops.length === 1 ? "" : "s"} (${label})`);
42
+ }
43
+ return ops.length;
44
+ }
45
+ catch {
46
+ /* non-fatal — auto-review must never break the turn loop / compaction */
47
+ return 0;
48
+ }
49
+ }
21
50
  /** Run the full compaction pipeline and persist a checkpoint. Returns the result. */
22
51
  export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
23
52
  runtime.bindRepo(ctx.cwd);
@@ -133,6 +162,14 @@ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
133
162
  /* non-fatal */
134
163
  }
135
164
  }
165
+ // S24 review-on-compact: when pressure is high, the just-compacted region is
166
+ // exactly the context worth remembering, so review it immediately rather than
167
+ // waiting for the next turn-cadence tick. Uses the shared runMemoryReview
168
+ // helper (fire-and-forget; doCompact is sync). Best-effort + non-fatal. Only
169
+ // fires above the `high` band so low-pressure compactions don't pay the cost.
170
+ if (!result.deduped && config.memoryAutoReview && runtime.pressureBand !== "low" && runtime.pressureBand !== "medium") {
171
+ void runMemoryReview(runtime, view, "pressure");
172
+ }
136
173
  // Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
137
174
  // skip re-vectorizing an already-compacted region (zero token cost).
138
175
  pi.appendEntry(MARKER_TYPE, {
@@ -17,7 +17,7 @@ import { toEngineMessages } from "../src/adapt.js";
17
17
  import { normalizeSessionId } from "../src/store.js";
18
18
  import { Logger } from "../src/log.js";
19
19
  import { recordModelSnapshot, latestModelSnapshot, upsertRepoRegistry, recordRepoModel } from "../src/store/sqlite.js";
20
- import { repoStateDir, resolveRepoRoot } from "./mega-config.js";
20
+ import { repoStateDir, resolveRepoRoot, pressureRatio, pressureFromPct, pressureBand } from "./mega-config.js";
21
21
  import { Dashboard } from "./mega-dashboard.js";
22
22
  export const STATUS_KEY = "mega-compact";
23
23
  export const WIDGET_KEY = "mega-compact-stats";
@@ -120,6 +120,24 @@ export class MegaRuntime {
120
120
  lastCtxTokens = null;
121
121
  lastCtxPercent = null;
122
122
  lastCtxWindow = 0;
123
+ /**
124
+ * Live 0–1 pressure: how full the context window is relative to the compaction
125
+ * threshold. Computed from the most recent context event the runtime already
126
+ * tracks (token count when available — the direct signal — otherwise the usage
127
+ * percentage). This is the single "how full" number every subsystem reads; the
128
+ * toolbar/dashboard tier label is `pressureBand` over this, so it climbs
129
+ * low→mega as context rises (S24). Always finite + in [0,1].
130
+ */
131
+ get pressure() {
132
+ if (this.lastCtxTokens != null && this.lastCtxTokens > 0 && this.config.thresholdTokens > 0) {
133
+ return pressureRatio(this.lastCtxTokens, this.config.thresholdTokens);
134
+ }
135
+ return pressureFromPct(this.lastCtxPercent);
136
+ }
137
+ /** Live discrete pressure band (low/medium/high/ultra/mega) over `pressure`. */
138
+ get pressureBand() {
139
+ return pressureBand(this.pressure);
140
+ }
123
141
  constructor(config) {
124
142
  this.config = config;
125
143
  this.store = new VectorStore({ dedupSim: config.dedupSim, stateDir: config.stateDir });
@@ -190,7 +208,11 @@ export class MegaRuntime {
190
208
  this.dashboard.snapshot({
191
209
  version: 1,
192
210
  updatedAt: new Date().toISOString(),
193
- tier: this.config.tier,
211
+ // S24: the headline tier is the LIVE pressure band; the env preset is kept
212
+ // alongside as presetTier so the dashboard can show both.
213
+ tier: this.pressureBand,
214
+ presetTier: this.config.tier,
215
+ pressure: this.pressure,
194
216
  config: {
195
217
  fastGatePct: this.config.fastGatePct,
196
218
  thresholdTokens: this.config.thresholdTokens,
@@ -236,6 +258,11 @@ export class MegaRuntime {
236
258
  const tokStr = this.lastCtxTokens != null ? `${Math.round(this.lastCtxTokens / 1000)}k` : "?";
237
259
  const maxStr = this.lastCtxWindow > 0 ? `${Math.round(this.lastCtxWindow / 1000)}k` : "?";
238
260
  const pctStr = this.lastCtxPercent != null ? `${Math.round(this.lastCtxPercent * 10) / 10}%` : "?%";
261
+ // S24: the tier label is the LIVE pressure band (low/medium/high/ultra/
262
+ // mega), not the static env preset. It climbs as context fills, so the
263
+ // user can see the system react. The base preset is shown as a dim suffix.
264
+ const liveBand = this.pressureBand;
265
+ const tierLabel = `${C.bold}${liveBand}${C.reset}${C.gray}·${this.config.tier}${C.reset}`;
239
266
  const triggerLabel = ready ? `${C.green}● ready${C.reset}` : armed ? `${C.amber}◐ armed${C.reset}` : `${C.gray}○ idle${C.reset}`;
240
267
  // Storage dedup rate is cumulative (store-wide, per-repo) and survives
241
268
  // session resets. Always show a number: 0% before any compaction, a
@@ -258,7 +285,7 @@ export class MegaRuntime {
258
285
  // Phase 3 — pulsing status glyph while a compaction is in flight.
259
286
  const pulse = this.pulsing ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} ` : "";
260
287
  const lines = [
261
- ` ${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}`,
288
+ ` ${C.amber}⚡ ${tierLabel}${C.reset} v${C.bold}${ownVersion()}${C.reset} │ ${tokStr}/${maxStr} tokens (${C.bold}${pctStr}${C.reset}) │ ${st.checkpointCount} saved${agentStr}${turnStr}`,
262
289
  ` ${triggerLabel} │ ${C.magenta}repeat-skipped: ${dedupStr}${C.reset} │ ${C.gray}memory held:${C.reset} ${usedStr} │ ${C.gray}space freed:${C.reset} ${savedStr}`,
263
290
  ];
264
291
  // Phase 3 — compact progress bar: session tokens saved toward the rolling goal.
@@ -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, } 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,78 @@ 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
+ // Use a small env cap for a fast, deterministic LRU check (the production
62
+ // default is 500; this exercises the same code path).
63
+ process.env.MEGACOMPACT_MEMORY_MAX_ROWS = "10";
64
+ try {
65
+ const dir = join(baseTmp, "lru");
66
+ const n = 10;
67
+ const seeds = n - 2;
68
+ for (let i = 0; i < seeds; i++)
69
+ addMemory({ content: `seed-${i}`, category: "note" }, null, dir);
70
+ const keep1 = addMemory({ content: "keep-recent-1", category: "note" }, null, dir);
71
+ const keep2 = addMemory({ content: "keep-recent-2", category: "note" }, null, dir);
72
+ // Mark the two as referenced so the LRU eviction spares them (they get a
73
+ // higher last_referenced than the un-referenced seeds).
74
+ assert.ok(referenceMemory(keep1, dir), "reference keep1");
75
+ assert.ok(referenceMemory(keep2, dir), "reference keep2");
76
+ // Insert 3 more — 3 over the cap across the inserts. The two referenced rows
77
+ // must survive; only un-referenced (oldest) seeds should be evicted.
78
+ addMemory({ content: "new-1", category: "note" }, null, dir);
79
+ addMemory({ content: "new-2", category: "note" }, null, dir);
80
+ addMemory({ content: "new-3", category: "note" }, null, dir);
81
+ const rows = listMemories(null, 1000, dir);
82
+ assert.equal(rows.length, n, "row count clamped to MEMORY_MAX_ROWS");
83
+ assert.ok(rows.some((m) => /keep-recent-1/.test(m.content)), "referenced row survived");
84
+ assert.ok(rows.some((m) => /keep-recent-2/.test(m.content)), "referenced row survived");
85
+ assert.ok(rows.some((m) => /new-3/.test(m.content)), "newest row present");
86
+ const seedRows = rows.filter((m) => /seed-/.test(m.content));
87
+ // 3 rows were evicted (the inserts pushed 3 past the cap); all evicted rows
88
+ // must be un-referenced seeds — the referenced rows survived above.
89
+ assert.equal(seedRows.length, seeds - 3, "exactly 3 oldest un-referenced seeds evicted");
90
+ assert.ok(!seedRows.some((m) => /seed-0/.test(m.content)), "oldest un-referenced seed evicted");
91
+ }
92
+ finally {
93
+ delete process.env.MEGACOMPACT_MEMORY_MAX_ROWS;
94
+ }
95
+ });
96
+ test("S24: MEGACOMPACT_MEMORY_MAX_CHARS env override truncates content", () => {
97
+ process.env.MEGACOMPACT_MEMORY_MAX_CHARS = "50";
98
+ try {
99
+ const dir = join(baseTmp, "cap-env");
100
+ const id = addMemory({ content: "x".repeat(500), category: "note" }, null, dir);
101
+ const rows = listMemories(null, 50, dir);
102
+ const row = rows.find((m) => m.id === id);
103
+ assert.ok(row, "row present");
104
+ assert.equal(row.content.length, 50 + "…[truncated]".length, "truncated to env cap + marker");
105
+ assert.ok(row.content.endsWith("…[truncated]"), "marker appended");
106
+ }
107
+ finally {
108
+ delete process.env.MEGACOMPACT_MEMORY_MAX_CHARS;
109
+ }
110
+ });
39
111
  test("cleanup memops", () => {
40
112
  rmSync(baseTmp, { recursive: true, force: true });
41
113
  });
@@ -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
+ });