pi-mega-compact 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -108
- 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 +34 -4
- 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 +36 -4
- 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/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,
|