pi-mega-compact 0.5.2 → 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.
- package/README.md +5 -4
- package/dist/extensions/dashboard-server.js +12 -3
- package/dist/extensions/mega-commands.js +5 -23
- package/dist/extensions/mega-compact.test.js +6 -3
- package/dist/extensions/mega-config.js +12 -9
- package/dist/extensions/mega-events.js +25 -20
- package/dist/extensions/mega-pipeline.js +22 -0
- package/dist/extensions/mega-runtime.js +30 -3
- package/dist/src/config.js +48 -0
- package/dist/src/memoryOps.test.js +50 -1
- package/dist/src/store/compression.test.js +24 -0
- package/dist/src/store/sqlite.js +60 -3
- package/extensions/dashboard-server.ts +14 -3
- package/extensions/mega-commands.ts +6 -25
- package/extensions/mega-compact.test.ts +8 -5
- package/extensions/mega-config.ts +26 -11
- package/extensions/mega-dashboard.ts +5 -0
- package/extensions/mega-events.ts +24 -19
- package/extensions/mega-pipeline.ts +22 -0
- package/extensions/mega-runtime.ts +32 -3
- package/package.json +1 -1
- package/src/config.ts +61 -0
- package/src/memoryOps.test.ts +59 -1
- package/src/store/compression.test.ts +27 -0
- package/src/store/sqlite.ts +56 -3
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.
|
|
9
|
+
> **Current version:** `v0.6.0` — 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
|
-
⚡
|
|
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
|
-
|
|
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=${
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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(`
|
|
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,7 @@ 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("
|
|
409
|
+
assert.ok(h.notifies.some((n) => n.includes("preset=custom") && n.includes("threshold=777")), "explicit threshold wins over tier (preset=custom)");
|
|
407
410
|
});
|
|
408
411
|
// ---- /dashboard commands ----------------------------------------------------
|
|
409
412
|
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
|
|
51
|
-
*
|
|
52
|
-
*
|
|
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
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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).
|
|
@@ -14,7 +14,7 @@ import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop } from "./mega-
|
|
|
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,31 @@ 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
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
+
try {
|
|
170
|
+
const { reviewConversation } = await import("../src/memory.js");
|
|
171
|
+
const { applyMemoryOps } = await import("../src/memoryOps.js");
|
|
172
|
+
const entries = ctx.sessionManager.getEntries();
|
|
173
|
+
const view = runtime.engineView(entries.flatMap((e) => (e.message ? [e.message] : [])));
|
|
174
|
+
const ops = reviewConversation(view, []);
|
|
175
|
+
if (ops.length) {
|
|
176
|
+
await applyMemoryOps(ops, runtime.currentStateDir);
|
|
177
|
+
// S21.2: a memory op landed in this turn window. The pipeline reads
|
|
178
|
+
// this counter after a successful compaction and fires
|
|
179
|
+
// `consolidateMemories` only when it's > 0.
|
|
180
|
+
runtime.memoriesTouchedThisCompaction += ops.length;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
/* non-fatal — auto-review must not break the turn loop */
|
|
177
185
|
}
|
|
178
|
-
}
|
|
179
|
-
catch {
|
|
180
|
-
/* non-fatal — auto-review must not break the turn loop */
|
|
181
186
|
}
|
|
182
187
|
}
|
|
183
188
|
});
|
|
@@ -133,6 +133,28 @@ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
|
|
|
133
133
|
/* non-fatal */
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
|
+
// S24 review-on-compact: when pressure is high, the just-compacted region is
|
|
137
|
+
// exactly the context worth remembering, so review it immediately rather than
|
|
138
|
+
// waiting for the next turn-cadence tick. Fire-and-forget (doCompact is sync):
|
|
139
|
+
// best-effort + non-fatal, paralleling the consolidate pass above. Only fires
|
|
140
|
+
// above the `high` band so low-pressure compactions don't pay the review cost.
|
|
141
|
+
if (!result.deduped && config.memoryAutoReview && runtime.pressureBand !== "low" && runtime.pressureBand !== "medium") {
|
|
142
|
+
void (async () => {
|
|
143
|
+
try {
|
|
144
|
+
const { reviewConversation } = await import("../src/memory.js");
|
|
145
|
+
const { applyMemoryOps } = await import("../src/memoryOps.js");
|
|
146
|
+
const ops = reviewConversation(view, []);
|
|
147
|
+
if (ops.length) {
|
|
148
|
+
await applyMemoryOps(ops, runtime.currentStateDir);
|
|
149
|
+
runtime.memoriesTouchedThisCompaction += ops.length;
|
|
150
|
+
runtime.pushTicker(`${C.green}🧠${C.reset} reviewed ${ops.length} memory op${ops.length === 1 ? "" : "s"} (pressure)`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
/* non-fatal — review-on-compact must never break the compaction */
|
|
155
|
+
}
|
|
156
|
+
})();
|
|
157
|
+
}
|
|
136
158
|
// Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
|
|
137
159
|
// skip re-vectorizing an already-compacted region (zero token cost).
|
|
138
160
|
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
|
-
|
|
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}⚡ ${
|
|
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.
|
package/dist/src/config.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/src/store/sqlite.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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=${
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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(`
|
|
430
|
-
`status should report
|
|
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("
|
|
444
|
-
"explicit threshold wins over tier (
|
|
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
|
-
/**
|
|
29
|
-
*
|
|
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
|
|
101
|
-
*
|
|
102
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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}⚡ ${
|
|
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.
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -39,3 +39,64 @@ export function preserveRecentForPressure(
|
|
|
39
39
|
const v = Math.round(preserveRecent - (preserveRecent - preserveRecentMin) * p);
|
|
40
40
|
return Math.max(preserveRecentMin, Math.min(preserveRecent, v));
|
|
41
41
|
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Discrete pressure band derived from the live 0–1 pressure ratio. This is the
|
|
45
|
+
* single signal every subsystem (tier label, trim depth, memory cadence)
|
|
46
|
+
* branches on, so context rising actually *moves* the dashboard/menu instead of
|
|
47
|
+
* sitting on a static env-resolved preset. (S24 — unified pressure signal.)
|
|
48
|
+
*
|
|
49
|
+
* Bands:
|
|
50
|
+
* low < 0.50 plenty of headroom — minimal trimming, infrequent review
|
|
51
|
+
* medium 0.50–0.75
|
|
52
|
+
* high 0.75–0.90
|
|
53
|
+
* ultra 0.90–1.00
|
|
54
|
+
* mega >= 1.00 at/over threshold — deepest trim, most aggressive review
|
|
55
|
+
*/
|
|
56
|
+
export type PressureBand = "low" | "medium" | "high" | "ultra" | "mega";
|
|
57
|
+
|
|
58
|
+
/** Clamp a pressure ratio into [0, 1]. */
|
|
59
|
+
function clamp01(p: number): number {
|
|
60
|
+
if (!Number.isFinite(p)) return 0;
|
|
61
|
+
return p < 0 ? 0 : p > 1 ? 1 : p;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Pressure as a 0–1 ratio from live token usage relative to the compaction
|
|
66
|
+
* threshold. Cheaper + more direct than deriving from a usage percentage when
|
|
67
|
+
* we already have both numbers (the context handler does). Re-exports
|
|
68
|
+
* `pressureFromPct` covers the percentage-only path. (S24.)
|
|
69
|
+
*/
|
|
70
|
+
export function pressureRatio(currentTokens: number, thresholdTokens: number): number {
|
|
71
|
+
if (!Number.isFinite(currentTokens) || currentTokens <= 0) return 0;
|
|
72
|
+
const t = Number.isFinite(thresholdTokens) && thresholdTokens > 0 ? thresholdTokens : 0;
|
|
73
|
+
return clamp01(t > 0 ? currentTokens / t : 0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Map a 0–1 pressure ratio to a discrete band. (S24.) */
|
|
77
|
+
export function pressureBand(pressure: number): PressureBand {
|
|
78
|
+
const p = clamp01(pressure);
|
|
79
|
+
if (p >= 1.0) return "mega";
|
|
80
|
+
if (p >= 0.9) return "ultra";
|
|
81
|
+
if (p >= 0.75) return "high";
|
|
82
|
+
if (p >= 0.5) return "medium";
|
|
83
|
+
return "low";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Memory auto-review cadence (in turns) for a given pressure band. As pressure
|
|
88
|
+
* climbs, the conversation is reviewed more often so durable memories keep pace
|
|
89
|
+
* with the faster context churn. Returns a divisor used as
|
|
90
|
+
* `turn % cadence === 0`. Always >= 1. (S24 — memory cadence tie-in.)
|
|
91
|
+
*/
|
|
92
|
+
export function memoryReviewCadence(band: PressureBand, baseInterval: number): number {
|
|
93
|
+
const base = baseInterval >= 1 ? baseInterval : 1;
|
|
94
|
+
switch (band) {
|
|
95
|
+
case "mega": return Math.max(1, Math.round(base / 5));
|
|
96
|
+
case "ultra": return Math.max(1, Math.round(base / 3));
|
|
97
|
+
case "high": return Math.max(1, Math.round(base / 2));
|
|
98
|
+
case "medium": return Math.max(1, Math.round((base * 2) / 3));
|
|
99
|
+
case "low":
|
|
100
|
+
default: return base;
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/memoryOps.test.ts
CHANGED
|
@@ -4,7 +4,14 @@ 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 {
|
|
7
|
+
import {
|
|
8
|
+
addMemory,
|
|
9
|
+
listMemories,
|
|
10
|
+
replaceMemory,
|
|
11
|
+
referenceMemory,
|
|
12
|
+
MEMORY_MAX_CHARS,
|
|
13
|
+
MEMORY_MAX_ROWS,
|
|
14
|
+
} from "./store/sqlite.js";
|
|
8
15
|
|
|
9
16
|
const baseTmp = mkdtempSync(join(tmpdir(), "mc-memops-"));
|
|
10
17
|
|
|
@@ -48,6 +55,57 @@ test("applyMemoryOps: REMOVE deletes the matching memory", async () => {
|
|
|
48
55
|
assert.ok(!rows.some((m) => /obsolete note/.test(m.content)), "removed");
|
|
49
56
|
});
|
|
50
57
|
|
|
58
|
+
test("S24: addMemory truncates content to MEMORY_MAX_CHARS", () => {
|
|
59
|
+
const dir = join(baseTmp, "cap");
|
|
60
|
+
const big = "x".repeat(MEMORY_MAX_CHARS + 5000);
|
|
61
|
+
const id = addMemory({ content: big, category: "note" }, null, dir);
|
|
62
|
+
const rows = listMemories(null, 50, dir);
|
|
63
|
+
const row = rows.find((m) => m.id === id);
|
|
64
|
+
assert.ok(row, "row present");
|
|
65
|
+
assert.ok(row!.content.length <= MEMORY_MAX_CHARS + 12, "content capped (incl. marker)");
|
|
66
|
+
assert.ok(row!.content.endsWith("…[truncated]"), "marker appended");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("S24: replaceMemory also truncates oversized content", () => {
|
|
70
|
+
const dir = join(baseTmp, "capreplace");
|
|
71
|
+
const id = addMemory({ content: "short", category: "note" }, null, dir);
|
|
72
|
+
const big = "y".repeat(MEMORY_MAX_CHARS + 1000);
|
|
73
|
+
replaceMemory(id, { content: big }, dir);
|
|
74
|
+
const rows = listMemories(null, 50, dir);
|
|
75
|
+
const row = rows.find((m) => m.id === id);
|
|
76
|
+
assert.ok(row, "row present");
|
|
77
|
+
assert.ok(row!.content.length <= MEMORY_MAX_CHARS + 12, "replaced content capped");
|
|
78
|
+
assert.ok(row!.content.endsWith("…[truncated]"), "marker appended");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("S24: addMemory evicts LRU rows past MEMORY_MAX_ROWS per repo", () => {
|
|
82
|
+
const dir = join(baseTmp, "lru");
|
|
83
|
+
const n = MEMORY_MAX_ROWS;
|
|
84
|
+
const seeds = n - 2;
|
|
85
|
+
for (let i = 0; i < seeds; i++) addMemory({ content: `seed-${i}`, category: "note" }, null, dir);
|
|
86
|
+
const keep1 = addMemory({ content: "keep-recent-1", category: "note" }, null, dir);
|
|
87
|
+
const keep2 = addMemory({ content: "keep-recent-2", category: "note" }, null, dir);
|
|
88
|
+
// Mark the two as referenced so the LRU eviction spares them (they get a
|
|
89
|
+
// higher last_referenced than the un-referenced seeds).
|
|
90
|
+
assert.ok(referenceMemory(keep1, dir), "reference keep1");
|
|
91
|
+
assert.ok(referenceMemory(keep2, dir), "reference keep2");
|
|
92
|
+
// Insert 3 more — 3 over the cap across the inserts. The two referenced rows
|
|
93
|
+
// must survive; only un-referenced (oldest) seeds should be evicted.
|
|
94
|
+
addMemory({ content: "new-1", category: "note" }, null, dir);
|
|
95
|
+
addMemory({ content: "new-2", category: "note" }, null, dir);
|
|
96
|
+
addMemory({ content: "new-3", category: "note" }, null, dir);
|
|
97
|
+
const rows = listMemories(null, 1000, dir);
|
|
98
|
+
assert.equal(rows.length, n, "row count clamped to MEMORY_MAX_ROWS");
|
|
99
|
+
assert.ok(rows.some((m) => /keep-recent-1/.test(m.content)), "referenced row survived");
|
|
100
|
+
assert.ok(rows.some((m) => /keep-recent-2/.test(m.content)), "referenced row survived");
|
|
101
|
+
assert.ok(rows.some((m) => /new-3/.test(m.content)), "newest row present");
|
|
102
|
+
const seedRows = rows.filter((m) => /seed-/.test(m.content));
|
|
103
|
+
// 3 rows were evicted (the inserts pushed 3 past the cap); all evicted rows
|
|
104
|
+
// must be un-referenced seeds — the referenced rows survived above.
|
|
105
|
+
assert.equal(seedRows.length, seeds - 3, "exactly 3 oldest un-referenced seeds evicted");
|
|
106
|
+
assert.ok(!seedRows.some((m) => /seed-0/.test(m.content)), "oldest un-referenced seed evicted");
|
|
107
|
+
});
|
|
108
|
+
|
|
51
109
|
test("cleanup memops", () => {
|
|
52
110
|
rmSync(baseTmp, { recursive: true, force: true });
|
|
53
111
|
});
|
|
@@ -139,3 +139,30 @@ test("pressureFromPct + preserveRecentForPressure scale with context (Fix E)", a
|
|
|
139
139
|
assert.equal(preserveRecentForPressure(0.5, 4, 2), 3, "p=0.5 → interpolates");
|
|
140
140
|
assert.ok(preserveRecentForPressure(1, 4, 2) >= 2, "never below floor");
|
|
141
141
|
});
|
|
142
|
+
|
|
143
|
+
test("S24: pressureRatio + pressureBand + memoryReviewCadence unify the signal", async () => {
|
|
144
|
+
const { pressureRatio, pressureBand, memoryReviewCadence } = await import("../config.js");
|
|
145
|
+
// pressureRatio: current/threshold, clamped to [0,1].
|
|
146
|
+
assert.equal(pressureRatio(50_000, 100_000), 0.5, "half threshold → 0.5");
|
|
147
|
+
assert.equal(pressureRatio(0, 100_000), 0, "no tokens → 0");
|
|
148
|
+
assert.equal(pressureRatio(10_000_000, 100_000), 1, "over threshold → clamped 1");
|
|
149
|
+
assert.equal(pressureRatio(50_000, 0), 0, "zero threshold → 0");
|
|
150
|
+
assert.equal(pressureRatio(NaN, 100_000), 0, "NaN current → 0");
|
|
151
|
+
|
|
152
|
+
// pressureBand: discrete bands drive the toolbar/dashboard tier label.
|
|
153
|
+
assert.equal(pressureBand(0.2), "low");
|
|
154
|
+
assert.equal(pressureBand(0.5), "medium");
|
|
155
|
+
assert.equal(pressureBand(0.75), "high");
|
|
156
|
+
assert.equal(pressureBand(0.9), "ultra");
|
|
157
|
+
assert.equal(pressureBand(1.0), "mega");
|
|
158
|
+
assert.equal(pressureBand(2.0), "mega", "over 1 → mega");
|
|
159
|
+
assert.equal(pressureBand(-1), "low", "below 0 → low");
|
|
160
|
+
|
|
161
|
+
// memoryReviewCadence: higher pressure → smaller (more frequent) divisor.
|
|
162
|
+
assert.equal(memoryReviewCadence("low", 10), 10, "low keeps base interval");
|
|
163
|
+
assert.equal(memoryReviewCadence("medium", 10), 7, "medium shortens");
|
|
164
|
+
assert.equal(memoryReviewCadence("high", 10), 5, "high halves");
|
|
165
|
+
assert.equal(memoryReviewCadence("ultra", 10), 3, "ultra shortens more");
|
|
166
|
+
assert.equal(memoryReviewCadence("mega", 10), 2, "mega near base/5");
|
|
167
|
+
assert.equal(memoryReviewCadence("high", 0), 1, "never below 1");
|
|
168
|
+
});
|
package/src/store/sqlite.ts
CHANGED
|
@@ -713,6 +713,51 @@ export function addLesson(
|
|
|
713
713
|
// One SQLite store for user-saved memories, scoped by repo. Mirrors the
|
|
714
714
|
// lessons/sessions pattern: all state lives in SQLite from day one.
|
|
715
715
|
|
|
716
|
+
// S24 storage hardening: keep each memory row bounded so the durable store can
|
|
717
|
+
// never blow a downstream consumer's per-entry buffer (e.g. pi's native
|
|
718
|
+
// file-backed memory caps a single entry at ~5k chars). We truncate content at
|
|
719
|
+
// MEMORY_MAX_CHARS and evict the least-recently-referenced rows past
|
|
720
|
+
// MEMORY_MAX_ROWS per repo via LRU. Both are SQLite-only (PREVENT-PI-004): no
|
|
721
|
+
// file-backed memory is written anywhere.
|
|
722
|
+
export const MEMORY_MAX_CHARS = 4000;
|
|
723
|
+
export const MEMORY_MAX_ROWS = 200;
|
|
724
|
+
|
|
725
|
+
/** Truncate memory content to the per-entry cap, preserving a trailing marker. */
|
|
726
|
+
function capMemoryContent(content: string): string {
|
|
727
|
+
if (content.length <= MEMORY_MAX_CHARS) return content;
|
|
728
|
+
return content.slice(0, MEMORY_MAX_CHARS) + "…[truncated]";
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Evict the least-recently-referenced rows for a repo past MEMORY_MAX_ROWS.
|
|
733
|
+
* LRU key = COALESCE(last_referenced, last_recalled_at, created_at) so a memory
|
|
734
|
+
* that is recalled/referenced survives over a stale one. Best-effort: any error
|
|
735
|
+
* is swallowed by the caller. Repo-scoped so one noisy repo can't evict another.
|
|
736
|
+
*/
|
|
737
|
+
function evictMemoryLru(repo: string | null, stateDir: string): void {
|
|
738
|
+
const db = openStore(stateDir);
|
|
739
|
+
// SQLite `= NULL` is never true, so the null-repo scope (memories are
|
|
740
|
+
// stateDir-scoped when repo is null — the applyMemoryOps path) needs `IS NULL`.
|
|
741
|
+
const where = repo == null ? "repo IS NULL" : "repo = ?";
|
|
742
|
+
const countRow = repo == null
|
|
743
|
+
? db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get()
|
|
744
|
+
: db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get(repo);
|
|
745
|
+
const count = (countRow as { n: number }).n;
|
|
746
|
+
const over = count - MEMORY_MAX_ROWS;
|
|
747
|
+
if (over <= 0) return;
|
|
748
|
+
// Delete the `over` least-recently-used rows. ORDER BY the LRU key ASC, id ASC
|
|
749
|
+
// (id ASC breaks ties deterministically — oldest created first). The `where`
|
|
750
|
+
// clause is a code-controlled constant (never user input) → PREVENT-002 OK.
|
|
751
|
+
const sql =
|
|
752
|
+
`DELETE FROM memories WHERE ${where} AND id IN (
|
|
753
|
+
SELECT id FROM memories WHERE ${where}
|
|
754
|
+
ORDER BY COALESCE(last_referenced, last_recalled_at, created_at) ASC, id ASC
|
|
755
|
+
LIMIT ?
|
|
756
|
+
)`;
|
|
757
|
+
if (repo == null) db.prepare(sql).run(over);
|
|
758
|
+
else db.prepare(sql).run(repo, repo, over);
|
|
759
|
+
}
|
|
760
|
+
|
|
716
761
|
export interface MemoryRecord {
|
|
717
762
|
id: number;
|
|
718
763
|
repo: string | null;
|
|
@@ -727,7 +772,10 @@ export interface MemoryRecord {
|
|
|
727
772
|
sourceTurn: number | null;
|
|
728
773
|
}
|
|
729
774
|
|
|
730
|
-
/** Save a memory to the current repo's store. Returns the new row id.
|
|
775
|
+
/** Save a memory to the current repo's store. Returns the new row id.
|
|
776
|
+
* S24 hardening: content is truncated to MEMORY_MAX_CHARS and, once the per-repo
|
|
777
|
+
* row count exceeds MEMORY_MAX_ROWS, the least-recently-used rows are evicted
|
|
778
|
+
* (LRU) so the store stays bounded. */
|
|
731
779
|
export function addMemory(
|
|
732
780
|
memory: { kind?: string; content: string; tags?: string[]; category?: string; target?: string; sourceTurn?: number },
|
|
733
781
|
repo: string | null,
|
|
@@ -743,13 +791,18 @@ export function addMemory(
|
|
|
743
791
|
.run(
|
|
744
792
|
repo ?? null,
|
|
745
793
|
memory.kind ?? "note",
|
|
746
|
-
memory.content,
|
|
794
|
+
capMemoryContent(memory.content),
|
|
747
795
|
JSON.stringify(memory.tags ?? []),
|
|
748
796
|
now,
|
|
749
797
|
memory.category ?? null,
|
|
750
798
|
memory.target ?? null,
|
|
751
799
|
memory.sourceTurn ?? null,
|
|
752
800
|
);
|
|
801
|
+
try {
|
|
802
|
+
evictMemoryLru(repo, stateDir);
|
|
803
|
+
} catch {
|
|
804
|
+
/* non-fatal: eviction must never fail an add */
|
|
805
|
+
}
|
|
753
806
|
return Number(res.lastInsertRowid);
|
|
754
807
|
}
|
|
755
808
|
|
|
@@ -808,7 +861,7 @@ export function replaceMemory(
|
|
|
808
861
|
)
|
|
809
862
|
.run(
|
|
810
863
|
patch.kind ?? null,
|
|
811
|
-
patch.content
|
|
864
|
+
patch.content != null ? capMemoryContent(patch.content) : null,
|
|
812
865
|
patch.tags ? JSON.stringify(patch.tags) : null,
|
|
813
866
|
"category" in patch ? (patch.category ?? null) : null,
|
|
814
867
|
"target" in patch ? (patch.target ?? null) : null,
|