pi-mega-compact 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,13 @@ 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
+ } from "./store/sqlite.js";
8
14
 
9
15
  const baseTmp = mkdtempSync(join(tmpdir(), "mc-memops-"));
10
16
 
@@ -48,6 +54,79 @@ test("applyMemoryOps: REMOVE deletes the matching memory", async () => {
48
54
  assert.ok(!rows.some((m) => /obsolete note/.test(m.content)), "removed");
49
55
  });
50
56
 
57
+ test("S24: addMemory truncates content to MEMORY_MAX_CHARS", () => {
58
+ const dir = join(baseTmp, "cap");
59
+ const big = "x".repeat(MEMORY_MAX_CHARS + 5000);
60
+ const id = addMemory({ content: big, category: "note" }, null, dir);
61
+ const rows = listMemories(null, 50, dir);
62
+ const row = rows.find((m) => m.id === id);
63
+ assert.ok(row, "row present");
64
+ assert.ok(row!.content.length <= MEMORY_MAX_CHARS + 12, "content capped (incl. marker)");
65
+ assert.ok(row!.content.endsWith("…[truncated]"), "marker appended");
66
+ });
67
+
68
+ test("S24: replaceMemory also truncates oversized content", () => {
69
+ const dir = join(baseTmp, "capreplace");
70
+ const id = addMemory({ content: "short", category: "note" }, null, dir);
71
+ const big = "y".repeat(MEMORY_MAX_CHARS + 1000);
72
+ replaceMemory(id, { content: big }, dir);
73
+ const rows = listMemories(null, 50, dir);
74
+ const row = rows.find((m) => m.id === id);
75
+ assert.ok(row, "row present");
76
+ assert.ok(row!.content.length <= MEMORY_MAX_CHARS + 12, "replaced content capped");
77
+ assert.ok(row!.content.endsWith("…[truncated]"), "marker appended");
78
+ });
79
+
80
+ test("S24: addMemory evicts LRU rows past MEMORY_MAX_ROWS per repo", () => {
81
+ // Use a small env cap for a fast, deterministic LRU check (the production
82
+ // default is 500; this exercises the same code path).
83
+ process.env.MEGACOMPACT_MEMORY_MAX_ROWS = "10";
84
+ try {
85
+ const dir = join(baseTmp, "lru");
86
+ const n = 10;
87
+ const seeds = n - 2;
88
+ for (let i = 0; i < seeds; i++) addMemory({ content: `seed-${i}`, category: "note" }, null, dir);
89
+ const keep1 = addMemory({ content: "keep-recent-1", category: "note" }, null, dir);
90
+ const keep2 = addMemory({ content: "keep-recent-2", category: "note" }, null, dir);
91
+ // Mark the two as referenced so the LRU eviction spares them (they get a
92
+ // higher last_referenced than the un-referenced seeds).
93
+ assert.ok(referenceMemory(keep1, dir), "reference keep1");
94
+ assert.ok(referenceMemory(keep2, dir), "reference keep2");
95
+ // Insert 3 more — 3 over the cap across the inserts. The two referenced rows
96
+ // must survive; only un-referenced (oldest) seeds should be evicted.
97
+ addMemory({ content: "new-1", category: "note" }, null, dir);
98
+ addMemory({ content: "new-2", category: "note" }, null, dir);
99
+ addMemory({ content: "new-3", category: "note" }, null, dir);
100
+ const rows = listMemories(null, 1000, dir);
101
+ assert.equal(rows.length, n, "row count clamped to MEMORY_MAX_ROWS");
102
+ assert.ok(rows.some((m) => /keep-recent-1/.test(m.content)), "referenced row survived");
103
+ assert.ok(rows.some((m) => /keep-recent-2/.test(m.content)), "referenced row survived");
104
+ assert.ok(rows.some((m) => /new-3/.test(m.content)), "newest row present");
105
+ const seedRows = rows.filter((m) => /seed-/.test(m.content));
106
+ // 3 rows were evicted (the inserts pushed 3 past the cap); all evicted rows
107
+ // must be un-referenced seeds — the referenced rows survived above.
108
+ assert.equal(seedRows.length, seeds - 3, "exactly 3 oldest un-referenced seeds evicted");
109
+ assert.ok(!seedRows.some((m) => /seed-0/.test(m.content)), "oldest un-referenced seed evicted");
110
+ } finally {
111
+ delete process.env.MEGACOMPACT_MEMORY_MAX_ROWS;
112
+ }
113
+ });
114
+
115
+ test("S24: MEGACOMPACT_MEMORY_MAX_CHARS env override truncates content", () => {
116
+ process.env.MEGACOMPACT_MEMORY_MAX_CHARS = "50";
117
+ try {
118
+ const dir = join(baseTmp, "cap-env");
119
+ const id = addMemory({ content: "x".repeat(500), category: "note" }, null, dir);
120
+ const rows = listMemories(null, 50, dir);
121
+ const row = rows.find((m) => m.id === id);
122
+ assert.ok(row, "row present");
123
+ assert.equal(row!.content.length, 50 + "…[truncated]".length, "truncated to env cap + marker");
124
+ assert.ok(row!.content.endsWith("…[truncated]"), "marker appended");
125
+ } finally {
126
+ delete process.env.MEGACOMPACT_MEMORY_MAX_CHARS;
127
+ }
128
+ });
129
+
51
130
  test("cleanup memops", () => {
52
131
  rmSync(baseTmp, { recursive: true, force: true });
53
132
  });
@@ -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,72 @@ 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. Defaults are overridable via env
722
+ // (MEGACOMPACT_MEMORY_MAX_CHARS / MEGACOMPACT_MEMORY_MAX_ROWS).
723
+ export const MEMORY_MAX_CHARS = 4000;
724
+ export const MEMORY_MAX_ROWS = 500;
725
+
726
+ /** Read an env override as a positive int, falling back to `fallback`. */
727
+ function envInt(name: string, fallback: number): number {
728
+ const v = process.env[name];
729
+ if (v == null || v === "") return fallback;
730
+ const n = Number(v);
731
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
732
+ }
733
+
734
+ /** Effective per-entry char cap (env-overridable, default MEMORY_MAX_CHARS). */
735
+ export function memoryMaxChars(): number {
736
+ return envInt("MEGACOMPACT_MEMORY_MAX_CHARS", MEMORY_MAX_CHARS);
737
+ }
738
+
739
+ /** Effective per-repo row cap (env-overridable, default MEMORY_MAX_ROWS). */
740
+ export function memoryMaxRows(): number {
741
+ return envInt("MEGACOMPACT_MEMORY_MAX_ROWS", MEMORY_MAX_ROWS);
742
+ }
743
+
744
+ /** Truncate memory content to the per-entry cap, preserving a trailing marker. */
745
+ function capMemoryContent(content: string): string {
746
+ const cap = memoryMaxChars();
747
+ if (content.length <= cap) return content;
748
+ return content.slice(0, cap) + "…[truncated]";
749
+ }
750
+
751
+ /**
752
+ * Evict the least-recently-referenced rows for a repo past MEMORY_MAX_ROWS.
753
+ * LRU key = COALESCE(last_referenced, last_recalled_at, created_at) so a memory
754
+ * that is recalled/referenced survives over a stale one. Best-effort: any error
755
+ * is swallowed by the caller. Repo-scoped so one noisy repo can't evict another.
756
+ */
757
+ function evictMemoryLru(repo: string | null, stateDir: string): void {
758
+ const db = openStore(stateDir);
759
+ const maxRows = memoryMaxRows();
760
+ // SQLite `= NULL` is never true, so the null-repo scope (memories are
761
+ // stateDir-scoped when repo is null — the applyMemoryOps path) needs `IS NULL`.
762
+ const where = repo == null ? "repo IS NULL" : "repo = ?";
763
+ const countRow = repo == null
764
+ ? db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get()
765
+ : db.prepare(`SELECT COUNT(*) AS n FROM memories WHERE ${where}`).get(repo);
766
+ const count = (countRow as { n: number }).n;
767
+ const over = count - maxRows;
768
+ if (over <= 0) return;
769
+ // Delete the `over` least-recently-used rows. ORDER BY the LRU key ASC, id ASC
770
+ // (id ASC breaks ties deterministically — oldest created first). The `where`
771
+ // clause is a code-controlled constant (never user input) → PREVENT-002 OK.
772
+ const sql =
773
+ `DELETE FROM memories WHERE ${where} AND id IN (
774
+ SELECT id FROM memories WHERE ${where}
775
+ ORDER BY COALESCE(last_referenced, last_recalled_at, created_at) ASC, id ASC
776
+ LIMIT ?
777
+ )`;
778
+ if (repo == null) db.prepare(sql).run(over);
779
+ else db.prepare(sql).run(repo, repo, over);
780
+ }
781
+
716
782
  export interface MemoryRecord {
717
783
  id: number;
718
784
  repo: string | null;
@@ -727,7 +793,10 @@ export interface MemoryRecord {
727
793
  sourceTurn: number | null;
728
794
  }
729
795
 
730
- /** Save a memory to the current repo's store. Returns the new row id. */
796
+ /** Save a memory to the current repo's store. Returns the new row id.
797
+ * S24 hardening: content is truncated to MEMORY_MAX_CHARS and, once the per-repo
798
+ * row count exceeds MEMORY_MAX_ROWS, the least-recently-used rows are evicted
799
+ * (LRU) so the store stays bounded. */
731
800
  export function addMemory(
732
801
  memory: { kind?: string; content: string; tags?: string[]; category?: string; target?: string; sourceTurn?: number },
733
802
  repo: string | null,
@@ -743,13 +812,18 @@ export function addMemory(
743
812
  .run(
744
813
  repo ?? null,
745
814
  memory.kind ?? "note",
746
- memory.content,
815
+ capMemoryContent(memory.content),
747
816
  JSON.stringify(memory.tags ?? []),
748
817
  now,
749
818
  memory.category ?? null,
750
819
  memory.target ?? null,
751
820
  memory.sourceTurn ?? null,
752
821
  );
822
+ try {
823
+ evictMemoryLru(repo, stateDir);
824
+ } catch {
825
+ /* non-fatal: eviction must never fail an add */
826
+ }
753
827
  return Number(res.lastInsertRowid);
754
828
  }
755
829
 
@@ -808,7 +882,7 @@ export function replaceMemory(
808
882
  )
809
883
  .run(
810
884
  patch.kind ?? null,
811
- patch.content ?? null,
885
+ patch.content != null ? capMemoryContent(patch.content) : null,
812
886
  patch.tags ? JSON.stringify(patch.tags) : null,
813
887
  "category" in patch ? (patch.category ?? null) : null,
814
888
  "target" in patch ? (patch.target ?? null) : null,