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/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
+ }
@@ -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 { addMemory, listMemories } from "./store/sqlite.js";
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
+ });
@@ -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 ?? null,
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,