pi-mega-compact 0.4.20 → 0.4.21

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.
@@ -0,0 +1,201 @@
1
+ /**
2
+ * conflict-scan.ts — detect other installed pi extensions that overlap with
3
+ * pi-mega-compact's two owned responsibilities:
4
+ *
5
+ * 1. Conversation auto-compaction (we hook session_before_compact).
6
+ * 2. Durable "save to memory" (we now keep a `memories` table in our SQLite).
7
+ *
8
+ * This is a DETECT-AND-WARN scanner only. pi has no pre-load / veto hook — one
9
+ * extension cannot block another from loading — so we inspect the installed
10
+ * package set at startup and on demand, then report overlaps. No config is
11
+ * mutated. (See memory `pi-memory-mcp-review` for the original conflict pattern.)
12
+ *
13
+ * Pi-agnostic: reads package.json + greps source. No pi runtime types, so it is
14
+ * unit-testable against a fixture node_modules tree.
15
+ */
16
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
17
+ import { join, dirname } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ // Marker sets. A package is flagged when its source matches a marker in a
20
+ // category. File-grep (not AST) keeps this dependency-free and fast.
21
+ const MARKERS = {
22
+ // Directly competes with our conversation compaction.
23
+ compaction: [
24
+ "session_before_compact",
25
+ "session_compact",
26
+ "compactSession",
27
+ "autoCompact",
28
+ "auto_compact",
29
+ ],
30
+ // Saves durable memory to its own store — the takeover target.
31
+ memory: [
32
+ "MEMORY_TOOL",
33
+ "learn-memory",
34
+ "saveMemory",
35
+ "memoryPolicy",
36
+ "wal_checkpoint",
37
+ "store/db.ts",
38
+ "memoryTool",
39
+ ],
40
+ // Tool-output shaping (compact/summarize tool results) — overlap, not a rival.
41
+ toolOutput: [
42
+ "tool_result",
43
+ "ToolResult",
44
+ ],
45
+ };
46
+ /** Resolve the node_modules dir that contains this package (or env override). */
47
+ export function resolveExtensionRoot(selfDir = dirname(fileURLToPath(import.meta.url))) {
48
+ const override = process.env.MEGACOMPACT_EXT_SCAN_DIR;
49
+ if (override && override.trim() !== "")
50
+ return override;
51
+ // selfDir is <root>/extensions or <root>/dist/extensions. Walk up to the
52
+ // node_modules that holds pi-mega-compact.
53
+ let dir = selfDir;
54
+ for (let i = 0; i < 6; i++) {
55
+ const candidate = join(dir, "node_modules");
56
+ if (existsSync(candidate) && existsSync(join(candidate, "pi-mega-compact")))
57
+ return candidate;
58
+ const parent = dirname(dir);
59
+ if (parent === dir)
60
+ break;
61
+ dir = parent;
62
+ }
63
+ return null;
64
+ }
65
+ /** Recursively collect source-ish files under a package, capped to avoid scans. */
66
+ function collectFiles(root, max = 400) {
67
+ const out = [];
68
+ const walk = (dir) => {
69
+ if (out.length >= max)
70
+ return;
71
+ let entries;
72
+ try {
73
+ entries = readdirSync(dir);
74
+ }
75
+ catch {
76
+ return;
77
+ }
78
+ for (const e of entries) {
79
+ if (out.length >= max)
80
+ return;
81
+ const full = join(dir, e);
82
+ let st;
83
+ try {
84
+ st = statSync(full);
85
+ }
86
+ catch {
87
+ continue;
88
+ }
89
+ if (st.isDirectory()) {
90
+ if (e === "node_modules" || e === ".git")
91
+ continue;
92
+ walk(full);
93
+ }
94
+ else if (/\.(ts|js|mjs|cjs|json|md)$/.test(e)) {
95
+ out.push(full);
96
+ }
97
+ }
98
+ };
99
+ walk(root);
100
+ return out;
101
+ }
102
+ /** Grep a package's source for any marker in `keys`; return matched markers. */
103
+ function matchMarkers(pkgDir, keys) {
104
+ const found = new Set();
105
+ let files;
106
+ try {
107
+ files = collectFiles(pkgDir);
108
+ }
109
+ catch {
110
+ return [];
111
+ }
112
+ for (const f of files) {
113
+ let text;
114
+ try {
115
+ text = readFileSync(f, "utf-8");
116
+ }
117
+ catch {
118
+ continue;
119
+ }
120
+ for (const m of keys) {
121
+ if (text.includes(m))
122
+ found.add(m);
123
+ }
124
+ if (found.size === keys.length)
125
+ break;
126
+ }
127
+ return [...found];
128
+ }
129
+ /**
130
+ * Scan installed extensions for overlaps with pi-mega-compact.
131
+ * @param selfName package name to skip (defaults to this package's name).
132
+ */
133
+ export function detectConflicts(selfName = "pi-mega-compact") {
134
+ const root = resolveExtensionRoot();
135
+ const scanned = [];
136
+ const conflicts = [];
137
+ if (!root || !existsSync(root))
138
+ return { scanned, conflicts };
139
+ let entries;
140
+ try {
141
+ entries = readdirSync(root);
142
+ }
143
+ catch {
144
+ return { scanned, conflicts };
145
+ }
146
+ for (const name of entries) {
147
+ const pkgDir = join(root, name);
148
+ if (!statSync(pkgDir).isDirectory())
149
+ continue;
150
+ const pkgJson = join(pkgDir, "package.json");
151
+ if (!existsSync(pkgJson))
152
+ continue;
153
+ let pkg;
154
+ try {
155
+ pkg = JSON.parse(readFileSync(pkgJson, "utf-8"));
156
+ }
157
+ catch {
158
+ continue;
159
+ }
160
+ const pkgName = pkg.name ?? name;
161
+ if (pkgName === selfName)
162
+ continue;
163
+ // Only consider packages that declare pi extensions.
164
+ if (!pkg.pi || !Array.isArray(pkg.pi.extensions) || pkg.pi.extensions.length === 0)
165
+ continue;
166
+ scanned.push(pkgName);
167
+ const memHits = matchMarkers(pkgDir, MARKERS.memory);
168
+ const compHits = matchMarkers(pkgDir, MARKERS.compaction);
169
+ const toolHits = matchMarkers(pkgDir, MARKERS.toolOutput);
170
+ if (compHits.length > 0) {
171
+ conflicts.push({
172
+ package: pkgName,
173
+ severity: "high",
174
+ kind: "compaction",
175
+ evidence: compHits,
176
+ recommendation: "Disabling recommended — competes with pi-mega-compact's conversation compaction.",
177
+ });
178
+ continue; // compaction is the dominant conflict; don't double-flag.
179
+ }
180
+ if (memHits.length > 0) {
181
+ conflicts.push({
182
+ package: pkgName,
183
+ severity: "high",
184
+ kind: "memory",
185
+ evidence: memHits,
186
+ recommendation: "pi-mega-compact now owns save-to-memory (/mega-memory, its own SQLite). Disable this to avoid duplicate memory stores.",
187
+ });
188
+ continue;
189
+ }
190
+ if (toolHits.length > 0) {
191
+ conflicts.push({
192
+ package: pkgName,
193
+ severity: "info",
194
+ kind: "tool-output",
195
+ evidence: toolHits,
196
+ recommendation: "Shapes tool output (summarize/compact tool results). Generally compatible; no action needed.",
197
+ });
198
+ }
199
+ }
200
+ return { scanned, conflicts };
201
+ }
@@ -29,10 +29,12 @@ import { MegaRuntime } from "./mega-runtime.js";
29
29
  import { registerEventHandlers } from "./mega-events.js";
30
30
  import { registerCommands } from "./mega-commands.js";
31
31
  import { registerDashboardCommands } from "./mega-dashboard-cmds.js";
32
+ import { registerConflictCommands } from "./mega-conflict-cmds.js";
32
33
  export default function (pi) {
33
34
  const config = loadConfig();
34
35
  const runtime = new MegaRuntime(config);
35
36
  registerEventHandlers(pi, runtime, config);
36
37
  registerCommands(pi, runtime, config);
37
38
  registerDashboardCommands(pi, runtime);
39
+ registerConflictCommands(pi, runtime);
38
40
  }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * mega-conflict-cmds.ts — extension conflict validator + save-to-memory command.
3
+ *
4
+ * Detects other installed extensions that overlap with pi-mega-compact
5
+ * (conversation compaction, or save-to-memory) and WARNs — pi has no pre-load
6
+ * veto hook, so this is detect-and-warn only. Also registers /mega-memory, our
7
+ * own durable memory store in SQLite (the takeover of memory extensions).
8
+ */
9
+ import { detectConflicts } from "./conflict-scan.js";
10
+ import { addMemory, listMemories, searchMemories, recallMemory } from "../src/store/sqlite.js";
11
+ import { resolveRepoRoot } from "./mega-config.js";
12
+ /** Run the conflict scan and format a human-readable report. */
13
+ export function validateExtensions() {
14
+ const report = detectConflicts();
15
+ const lines = [];
16
+ if (report.conflicts.length === 0) {
17
+ lines.push(`[mega-compact] conflict check: ${report.scanned.length} extensions scanned, no overlaps.`);
18
+ return { report, lines };
19
+ }
20
+ const high = report.conflicts.filter((c) => c.severity === "high");
21
+ lines.push(`[mega-compact] conflict check: ${report.scanned.length} scanned, ${report.conflicts.length} overlap(s), ${high.length} high-severity.`);
22
+ for (const c of report.conflicts) {
23
+ const tag = c.severity === "high" ? "⚠ HIGH" : "ℹ info";
24
+ lines.push(` ${tag} ${c.package} — ${c.kind} — ${c.recommendation}`);
25
+ }
26
+ return { report, lines };
27
+ }
28
+ /** Run the scan at activation and surface a one-line warning if needed. */
29
+ export function runLoadTimeConflictCheck() {
30
+ try {
31
+ const { lines } = validateExtensions();
32
+ const high = lines.filter((l) => l.includes("⚠ HIGH"));
33
+ if (high.length > 0) {
34
+ // Non-fatal: just inform on stderr; the dashboard/commands carry details.
35
+ console.warn(lines.join("\n"));
36
+ }
37
+ }
38
+ catch {
39
+ /* best-effort; never block session load */
40
+ }
41
+ }
42
+ function memoryLine(m) {
43
+ const tags = m.tags.length ? ` [${m.tags.join(", ")}]` : "";
44
+ const snap = m.content.length > 80 ? m.content.slice(0, 77) + "…" : m.content;
45
+ return `#${m.id} (${m.kind})${tags}: ${snap}`;
46
+ }
47
+ /** Register conflict-check + memory commands. */
48
+ export function registerConflictCommands(pi, runtime) {
49
+ runLoadTimeConflictCheck();
50
+ pi.registerCommand("mega-compat-check", {
51
+ description: "Scan installed extensions for overlaps with pi-mega-compact (compaction / save-to-memory) and warn.",
52
+ handler: async (_args, ctx) => {
53
+ const { lines } = validateExtensions();
54
+ for (const l of lines)
55
+ ctx.ui.notify(l);
56
+ if (lines.length === 1) {
57
+ ctx.ui.notify("[mega-compact] You own compaction + memory; no conflicting extensions detected.");
58
+ }
59
+ },
60
+ });
61
+ pi.registerCommand("mega-memory", {
62
+ description: "Save and recall durable memory in pi-mega-compact's SQLite store. Usage: /mega-memory save <text> | list | search <q> | recall <id>",
63
+ handler: async (args, ctx) => {
64
+ const repo = resolveRepoRoot(ctx.cwd) ?? runtime.currentStateDir;
65
+ const parts = args.trim().split(/\s+/);
66
+ const sub = parts[0]?.toLowerCase() ?? "list";
67
+ if (sub === "save") {
68
+ const text = args.trim().slice(4).trim();
69
+ if (!text) {
70
+ ctx.ui.notify("[mega-memory] usage: /mega-memory save <text>");
71
+ return;
72
+ }
73
+ // Optional "#tag #tag" parsing from the tail.
74
+ const tagMatches = [...text.matchAll(/#([\w-]+)/g)].map((m) => m[1]);
75
+ const content = text.replace(/#[\w-]+/g, "").trim();
76
+ const id = addMemory({ content, tags: tagMatches }, repo, runtime.currentStateDir);
77
+ ctx.ui.notify(`[mega-memory] saved #${id} to ${repo.split(/[\\/]/).pop()}`);
78
+ return;
79
+ }
80
+ if (sub === "search") {
81
+ const q = parts.slice(1).join(" ").trim();
82
+ if (!q) {
83
+ ctx.ui.notify("[mega-memory] usage: /mega-memory search <query>");
84
+ return;
85
+ }
86
+ const hits = searchMemories(q, repo, 50, runtime.currentStateDir);
87
+ if (!hits.length) {
88
+ ctx.ui.notify("[mega-memory] no memories match.");
89
+ return;
90
+ }
91
+ for (const m of hits)
92
+ ctx.ui.notify(memoryLine(m));
93
+ return;
94
+ }
95
+ if (sub === "recall") {
96
+ const id = Number(parts[1]);
97
+ if (!Number.isFinite(id) || parts[1] === undefined) {
98
+ ctx.ui.notify("[mega-memory] usage: /mega-memory recall <id>");
99
+ return;
100
+ }
101
+ if (recallMemory(id, runtime.currentStateDir)) {
102
+ const found = listMemories(repo, 1000, runtime.currentStateDir).find((m) => m.id === id);
103
+ ctx.ui.notify(found ? `[mega-memory] ${memoryLine(found)}` : `[mega-memory] recalled #${id}`);
104
+ }
105
+ else {
106
+ ctx.ui.notify(`[mega-memory] #${id} not found.`);
107
+ }
108
+ return;
109
+ }
110
+ // default: list
111
+ const all = listMemories(repo, 50, runtime.currentStateDir);
112
+ if (!all.length) {
113
+ ctx.ui.notify("[mega-memory] no saved memories yet. Use /mega-memory save <text>.");
114
+ return;
115
+ }
116
+ ctx.ui.notify(`[mega-memory] ${all.length} saved to ${repo.split(/[\\/]/).pop()}:`);
117
+ for (const m of all)
118
+ ctx.ui.notify(memoryLine(m));
119
+ },
120
+ });
121
+ }
@@ -56,15 +56,13 @@ export function runCompact(pi, runtime, config, ctx, messages, opts = {}) {
56
56
  // denominator (we don't want it pinned at 100% once we pass an old target).
57
57
  if (runtime.rt.tokensSaved > runtime.savedGoal)
58
58
  runtime.savedGoal = Math.ceil((runtime.rt.tokensSaved * 1.25) / 10_000) * 10_000;
59
- // Live toolbar "now processing" line: what file/region just got compacted or
60
- // deduped. Reset to the last-seen action after a few seconds (see snapshot).
59
+ // Live toolbar activity: what file/region just got compacted or deduped.
60
+ // Rendered via the rotating ticker line (see snapshot); the ring buffer is
61
+ // cycled one-per-repaint so the single line scrolls through recent files.
61
62
  const files = result.filesModified ?? [];
62
63
  const fileLabel = files.length
63
64
  ? files.map((f) => f.split("/").pop() ?? f).slice(0, 2).join(", ")
64
65
  : result.regionHash.slice(0, 8);
65
- runtime.currentActivity = result.deduped
66
- ? `♻ deduped ${fileLabel}`
67
- : `🗜 compacted ${result.checkpointId} · ${fileLabel}`;
68
66
  runtime.lastActivityAt = Date.now();
69
67
  // Explain-why line: surfaced while fresh. Pulls the dedup reason (which for
70
68
  // L2 includes the cosine sim) so the user sees WHY a region was kept/dropped.
@@ -68,10 +68,7 @@ export class MegaRuntime {
68
68
  // on model_select + session_start; persisted to SQL so cost + the dashboard
69
69
  // can read it without a live ctx.
70
70
  currentModel;
71
- // Live "what it's doing right now" line for the toolbar. Set on each
72
- // compaction; shown in teal while recent, then kept as the last-seen action so
73
- // the widget is never blank. Cleared on session reset.
74
- currentActivity;
71
+ // Live "what it's doing right now" timestamp, used for the fresh-window.
75
72
  lastActivityAt = 0;
76
73
  // Live per-tier dedup trace (Phase 1): e.g. "L0 ✓ → L1 ✓ → L2 0.91 → stored".
77
74
  // Built from the store's sync onTier callback during a compaction so the user
@@ -243,29 +240,27 @@ export class MegaRuntime {
243
240
  const bar = "▓".repeat(filled) + "░".repeat(10 - filled);
244
241
  lines.push(` ${C.green}saved ${fmt(this.rt.tokensSaved)} ${bar}${C.reset} ${pct}% of ${fmt(goal)}`);
245
242
  }
246
- // Live "now processing" line teal while fresh (≤4s), then the last-seen
247
- // action keeps the widget lively. Cleared on session reset.
243
+ // Live "now processing" line + why + recent deduped/compacted events,
244
+ // collapsed to ONE rotating line (fresh only). The ticker ring buffer
245
+ // (≤5 most-recent events) is cycled one-per-repaint so the line scrolls
246
+ // through recent files in real time while activity fires. We rotate on a
247
+ // 250ms step (same cadence as the pulse), using an event counter as the
248
+ // deterministic phase so consecutive repaints advance the visible entry.
248
249
  const fresh = Date.now() - this.lastActivityAt < 4000;
249
250
  if (this.tierTrace && fresh) {
250
251
  lines.push(` ${pulse}${this.tierTrace}`);
251
252
  }
252
- else if (this.currentActivity) {
253
- lines.push(` ${fresh ? C.teal : C.dim}${this.currentActivity}${C.reset}`);
253
+ else if (this.ticker.length > 0) {
254
+ const step = Math.floor(Date.now() / 250);
255
+ const idx = this.ticker.length - 1 - (step % this.ticker.length);
256
+ const head = this.ticker[idx].text;
257
+ const why = this.lastWhy ? ` ${C.gray}· ${this.lastWhy}${C.reset}` : "";
258
+ const more = this.ticker.length > 1 ? ` ${C.dim}(+${this.ticker.length - 1} more)${C.reset}` : "";
259
+ lines.push(` ${fresh ? C.teal : C.dim}${head}${why}${more}${C.reset}`);
254
260
  }
255
261
  else if (this.pulsing) {
256
262
  lines.push(` ${pulse}${C.teal}compacting…${C.reset}`);
257
263
  }
258
- // Phase 3 — explain-why line (fresh only).
259
- if (this.lastWhy && fresh)
260
- lines.push(` ${C.gray}${this.lastWhy}${C.reset}`);
261
- // Phase 3 — recall/activity ticker (most-recent first), fresh only.
262
- if (fresh) {
263
- for (let i = this.ticker.length - 1; i >= 0; i--) {
264
- if (lines.length >= 9)
265
- break; // leave room for the hint line (MAX 10)
266
- lines.push(` ${i === this.ticker.length - 1 ? "" : C.dim}${this.ticker[i].text}${C.reset}`);
267
- }
268
- }
269
264
  // Plain-language hint so first-time users understand the widget. Always
270
265
  // last, dimmed. "/mega-help explains these terms."
271
266
  if (lines.length < 10) {
@@ -295,7 +290,6 @@ export class MegaRuntime {
295
290
  this.statusKey = undefined;
296
291
  this.activeAgents = 0;
297
292
  this.currentTurn = 0;
298
- this.currentActivity = undefined;
299
293
  this.lastActivityAt = 0;
300
294
  this.tierTrace = undefined;
301
295
  this.ticker.length = 0;
@@ -359,6 +359,20 @@ function initSchema(db) {
359
359
  ts INTEGER
360
360
  );
361
361
 
362
+ -- Durable "save to memory" store (taken over from memory extensions).
363
+ -- One row per saved memory; scoped by repo so memory travels with the
364
+ -- clone. All params are parameterized (PREVENT-002).
365
+ CREATE TABLE IF NOT EXISTS memories (
366
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
367
+ repo TEXT,
368
+ kind TEXT DEFAULT 'note', -- note | fact | decision | preference
369
+ content TEXT NOT NULL,
370
+ tags TEXT, -- JSON array of strings
371
+ created_at INTEGER,
372
+ last_recalled_at INTEGER
373
+ );
374
+ CREATE INDEX IF NOT EXISTS idx_memories_repo ON memories(repo);
375
+
362
376
  -- FTS5 trigram virtual table (Sprint 9+ pg_trgm-equivalent verification).
363
377
  CREATE VIRTUAL TABLE IF NOT EXISTS context_chunks_trgm USING fts5(
364
378
  id UNINDEXED,
@@ -476,6 +490,51 @@ export function addLesson(sessionId, repo, lesson, stateDir = getStateDir()) {
476
490
  const now = Math.floor(Date.now() / 1000);
477
491
  db.prepare(`INSERT INTO lessons(session_id, repo, lesson, ts) VALUES(?, ?, ?, ?)`).run(normalizeSessionId(sessionId), repo ?? null, lesson, now);
478
492
  }
493
+ /** Save a memory to the current repo's store. Returns the new row id. */
494
+ export function addMemory(memory, repo, stateDir = getStateDir()) {
495
+ const db = openStore(stateDir);
496
+ const now = Math.floor(Date.now() / 1000);
497
+ const res = db
498
+ .prepare(`INSERT INTO memories(repo, kind, content, tags, created_at, last_recalled_at)
499
+ VALUES(?, ?, ?, ?, ?, NULL)`)
500
+ .run(repo ?? null, memory.kind ?? "note", memory.content, JSON.stringify(memory.tags ?? []), now);
501
+ return Number(res.lastInsertRowid);
502
+ }
503
+ /** List recent memories for a repo (or all repos when repo is null). */
504
+ export function listMemories(repo, limit = 50, stateDir = getStateDir()) {
505
+ const db = openStore(stateDir);
506
+ const rows = repo
507
+ ? db.prepare("SELECT * FROM memories WHERE repo = ? ORDER BY created_at DESC LIMIT ?").all(repo, limit)
508
+ : db.prepare("SELECT * FROM memories ORDER BY created_at DESC LIMIT ?").all(limit);
509
+ return rows.map(mapMemoryRow);
510
+ }
511
+ /** Substring search across content + tags. */
512
+ export function searchMemories(query, repo = null, limit = 50, stateDir = getStateDir()) {
513
+ const db = openStore(stateDir);
514
+ const like = `%${query}%`;
515
+ const rows = repo
516
+ ? db.prepare("SELECT * FROM memories WHERE repo = ? AND (content LIKE ? OR tags LIKE ?) ORDER BY created_at DESC LIMIT ?").all(repo, like, like, limit)
517
+ : db.prepare("SELECT * FROM memories WHERE content LIKE ? OR tags LIKE ? ORDER BY created_at DESC LIMIT ?").all(like, like, limit);
518
+ return rows.map(mapMemoryRow);
519
+ }
520
+ /** Mark a memory as recalled (updates last_recalled_at). Returns true if found. */
521
+ export function recallMemory(id, stateDir = getStateDir()) {
522
+ const db = openStore(stateDir);
523
+ const now = Math.floor(Date.now() / 1000);
524
+ const res = db.prepare("UPDATE memories SET last_recalled_at = ? WHERE id = ?").run(now, id);
525
+ return res.changes > 0;
526
+ }
527
+ function mapMemoryRow(row) {
528
+ return {
529
+ id: row.id,
530
+ repo: row.repo ?? null,
531
+ kind: row.kind ?? "note",
532
+ content: row.content ?? "",
533
+ tags: row.tags ? JSON.parse(row.tags) : [],
534
+ createdAt: row.created_at ?? 0,
535
+ lastRecalledAt: row.last_recalled_at ?? null,
536
+ };
537
+ }
479
538
  /** Map a DB row to the public StoredCheckpoint shape. */
480
539
  function rowToCheckpoint(row) {
481
540
  return {
@@ -0,0 +1,209 @@
1
+ /**
2
+ * conflict-scan.ts — detect other installed pi extensions that overlap with
3
+ * pi-mega-compact's two owned responsibilities:
4
+ *
5
+ * 1. Conversation auto-compaction (we hook session_before_compact).
6
+ * 2. Durable "save to memory" (we now keep a `memories` table in our SQLite).
7
+ *
8
+ * This is a DETECT-AND-WARN scanner only. pi has no pre-load / veto hook — one
9
+ * extension cannot block another from loading — so we inspect the installed
10
+ * package set at startup and on demand, then report overlaps. No config is
11
+ * mutated. (See memory `pi-memory-mcp-review` for the original conflict pattern.)
12
+ *
13
+ * Pi-agnostic: reads package.json + greps source. No pi runtime types, so it is
14
+ * unit-testable against a fixture node_modules tree.
15
+ */
16
+
17
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
18
+ import { join, dirname } from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ export type ConflictKind = "compaction" | "memory" | "tool-output";
22
+ export type ConflictSeverity = "high" | "info";
23
+
24
+ export interface ConflictHit {
25
+ package: string;
26
+ severity: ConflictSeverity;
27
+ kind: ConflictKind;
28
+ evidence: string[];
29
+ /** One-line recommended action for the user. */
30
+ recommendation: string;
31
+ }
32
+
33
+ export interface ConflictReport {
34
+ scanned: string[];
35
+ conflicts: ConflictHit[];
36
+ }
37
+
38
+ // Marker sets. A package is flagged when its source matches a marker in a
39
+ // category. File-grep (not AST) keeps this dependency-free and fast.
40
+ const MARKERS = {
41
+ // Directly competes with our conversation compaction.
42
+ compaction: [
43
+ "session_before_compact",
44
+ "session_compact",
45
+ "compactSession",
46
+ "autoCompact",
47
+ "auto_compact",
48
+ ],
49
+ // Saves durable memory to its own store — the takeover target.
50
+ memory: [
51
+ "MEMORY_TOOL",
52
+ "learn-memory",
53
+ "saveMemory",
54
+ "memoryPolicy",
55
+ "wal_checkpoint",
56
+ "store/db.ts",
57
+ "memoryTool",
58
+ ],
59
+ // Tool-output shaping (compact/summarize tool results) — overlap, not a rival.
60
+ toolOutput: [
61
+ "tool_result",
62
+ "ToolResult",
63
+ ],
64
+ } as const;
65
+
66
+ /** Resolve the node_modules dir that contains this package (or env override). */
67
+ export function resolveExtensionRoot(selfDir: string = dirname(fileURLToPath(import.meta.url))): string | null {
68
+ const override = process.env.MEGACOMPACT_EXT_SCAN_DIR;
69
+ if (override && override.trim() !== "") return override;
70
+ // selfDir is <root>/extensions or <root>/dist/extensions. Walk up to the
71
+ // node_modules that holds pi-mega-compact.
72
+ let dir = selfDir;
73
+ for (let i = 0; i < 6; i++) {
74
+ const candidate = join(dir, "node_modules");
75
+ if (existsSync(candidate) && existsSync(join(candidate, "pi-mega-compact"))) return candidate;
76
+ const parent = dirname(dir);
77
+ if (parent === dir) break;
78
+ dir = parent;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /** Recursively collect source-ish files under a package, capped to avoid scans. */
84
+ function collectFiles(root: string, max = 400): string[] {
85
+ const out: string[] = [];
86
+ const walk = (dir: string): void => {
87
+ if (out.length >= max) return;
88
+ let entries: string[];
89
+ try {
90
+ entries = readdirSync(dir);
91
+ } catch {
92
+ return;
93
+ }
94
+ for (const e of entries) {
95
+ if (out.length >= max) return;
96
+ const full = join(dir, e);
97
+ let st;
98
+ try {
99
+ st = statSync(full);
100
+ } catch {
101
+ continue;
102
+ }
103
+ if (st.isDirectory()) {
104
+ if (e === "node_modules" || e === ".git") continue;
105
+ walk(full);
106
+ } else if (/\.(ts|js|mjs|cjs|json|md)$/.test(e)) {
107
+ out.push(full);
108
+ }
109
+ }
110
+ };
111
+ walk(root);
112
+ return out;
113
+ }
114
+
115
+ /** Grep a package's source for any marker in `keys`; return matched markers. */
116
+ function matchMarkers(pkgDir: string, keys: readonly string[]): string[] {
117
+ const found = new Set<string>();
118
+ let files: string[];
119
+ try {
120
+ files = collectFiles(pkgDir);
121
+ } catch {
122
+ return [];
123
+ }
124
+ for (const f of files) {
125
+ let text: string;
126
+ try {
127
+ text = readFileSync(f, "utf-8");
128
+ } catch {
129
+ continue;
130
+ }
131
+ for (const m of keys) {
132
+ if (text.includes(m)) found.add(m);
133
+ }
134
+ if (found.size === keys.length) break;
135
+ }
136
+ return [...found];
137
+ }
138
+
139
+ /**
140
+ * Scan installed extensions for overlaps with pi-mega-compact.
141
+ * @param selfName package name to skip (defaults to this package's name).
142
+ */
143
+ export function detectConflicts(selfName = "pi-mega-compact"): ConflictReport {
144
+ const root = resolveExtensionRoot();
145
+ const scanned: string[] = [];
146
+ const conflicts: ConflictHit[] = [];
147
+ if (!root || !existsSync(root)) return { scanned, conflicts };
148
+
149
+ let entries: string[];
150
+ try {
151
+ entries = readdirSync(root);
152
+ } catch {
153
+ return { scanned, conflicts };
154
+ }
155
+
156
+ for (const name of entries) {
157
+ const pkgDir = join(root, name);
158
+ if (!statSync(pkgDir).isDirectory()) continue;
159
+ const pkgJson = join(pkgDir, "package.json");
160
+ if (!existsSync(pkgJson)) continue;
161
+ let pkg: { name?: string; pi?: { extensions?: string[] } };
162
+ try {
163
+ pkg = JSON.parse(readFileSync(pkgJson, "utf-8"));
164
+ } catch {
165
+ continue;
166
+ }
167
+ const pkgName = pkg.name ?? name;
168
+ if (pkgName === selfName) continue;
169
+ // Only consider packages that declare pi extensions.
170
+ if (!pkg.pi || !Array.isArray(pkg.pi.extensions) || pkg.pi.extensions.length === 0) continue;
171
+ scanned.push(pkgName);
172
+
173
+ const memHits = matchMarkers(pkgDir, MARKERS.memory);
174
+ const compHits = matchMarkers(pkgDir, MARKERS.compaction);
175
+ const toolHits = matchMarkers(pkgDir, MARKERS.toolOutput);
176
+
177
+ if (compHits.length > 0) {
178
+ conflicts.push({
179
+ package: pkgName,
180
+ severity: "high",
181
+ kind: "compaction",
182
+ evidence: compHits,
183
+ recommendation: "Disabling recommended — competes with pi-mega-compact's conversation compaction.",
184
+ });
185
+ continue; // compaction is the dominant conflict; don't double-flag.
186
+ }
187
+ if (memHits.length > 0) {
188
+ conflicts.push({
189
+ package: pkgName,
190
+ severity: "high",
191
+ kind: "memory",
192
+ evidence: memHits,
193
+ recommendation: "pi-mega-compact now owns save-to-memory (/mega-memory, its own SQLite). Disable this to avoid duplicate memory stores.",
194
+ });
195
+ continue;
196
+ }
197
+ if (toolHits.length > 0) {
198
+ conflicts.push({
199
+ package: pkgName,
200
+ severity: "info",
201
+ kind: "tool-output",
202
+ evidence: toolHits,
203
+ recommendation: "Shapes tool output (summarize/compact tool results). Generally compatible; no action needed.",
204
+ });
205
+ }
206
+ }
207
+
208
+ return { scanned, conflicts };
209
+ }
@@ -31,6 +31,7 @@ import { MegaRuntime } from "./mega-runtime.js";
31
31
  import { registerEventHandlers } from "./mega-events.js";
32
32
  import { registerCommands } from "./mega-commands.js";
33
33
  import { registerDashboardCommands } from "./mega-dashboard-cmds.js";
34
+ import { registerConflictCommands } from "./mega-conflict-cmds.js";
34
35
 
35
36
  export default function (pi: ExtensionAPI) {
36
37
  const config = loadConfig();
@@ -38,4 +39,5 @@ export default function (pi: ExtensionAPI) {
38
39
  registerEventHandlers(pi, runtime, config);
39
40
  registerCommands(pi, runtime, config);
40
41
  registerDashboardCommands(pi, runtime);
42
+ registerConflictCommands(pi, runtime);
41
43
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * mega-conflict-cmds.ts — extension conflict validator + save-to-memory command.
3
+ *
4
+ * Detects other installed extensions that overlap with pi-mega-compact
5
+ * (conversation compaction, or save-to-memory) and WARNs — pi has no pre-load
6
+ * veto hook, so this is detect-and-warn only. Also registers /mega-memory, our
7
+ * own durable memory store in SQLite (the takeover of memory extensions).
8
+ */
9
+
10
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import { detectConflicts, type ConflictReport } from "./conflict-scan.js";
12
+ import { addMemory, listMemories, searchMemories, recallMemory, type MemoryRecord } from "../src/store/sqlite.js";
13
+ import { resolveRepoRoot } from "./mega-config.js";
14
+ import { MegaRuntime } from "./mega-runtime.js";
15
+
16
+ /** Run the conflict scan and format a human-readable report. */
17
+ export function validateExtensions(): { report: ConflictReport; lines: string[] } {
18
+ const report = detectConflicts();
19
+ const lines: string[] = [];
20
+ if (report.conflicts.length === 0) {
21
+ lines.push(`[mega-compact] conflict check: ${report.scanned.length} extensions scanned, no overlaps.`);
22
+ return { report, lines };
23
+ }
24
+ const high = report.conflicts.filter((c) => c.severity === "high");
25
+ lines.push(`[mega-compact] conflict check: ${report.scanned.length} scanned, ${report.conflicts.length} overlap(s), ${high.length} high-severity.`);
26
+ for (const c of report.conflicts) {
27
+ const tag = c.severity === "high" ? "⚠ HIGH" : "ℹ info";
28
+ lines.push(` ${tag} ${c.package} — ${c.kind} — ${c.recommendation}`);
29
+ }
30
+ return { report, lines };
31
+ }
32
+
33
+ /** Run the scan at activation and surface a one-line warning if needed. */
34
+ export function runLoadTimeConflictCheck(): void {
35
+ try {
36
+ const { lines } = validateExtensions();
37
+ const high = lines.filter((l) => l.includes("⚠ HIGH"));
38
+ if (high.length > 0) {
39
+ // Non-fatal: just inform on stderr; the dashboard/commands carry details.
40
+ console.warn(lines.join("\n"));
41
+ }
42
+ } catch {
43
+ /* best-effort; never block session load */
44
+ }
45
+ }
46
+
47
+ function memoryLine(m: MemoryRecord): string {
48
+ const tags = m.tags.length ? ` [${m.tags.join(", ")}]` : "";
49
+ const snap = m.content.length > 80 ? m.content.slice(0, 77) + "…" : m.content;
50
+ return `#${m.id} (${m.kind})${tags}: ${snap}`;
51
+ }
52
+
53
+ /** Register conflict-check + memory commands. */
54
+ export function registerConflictCommands(pi: ExtensionAPI, runtime: MegaRuntime): void {
55
+ runLoadTimeConflictCheck();
56
+
57
+ pi.registerCommand("mega-compat-check", {
58
+ description: "Scan installed extensions for overlaps with pi-mega-compact (compaction / save-to-memory) and warn.",
59
+ handler: async (_args: string, ctx: ExtensionContext) => {
60
+ const { lines } = validateExtensions();
61
+ for (const l of lines) ctx.ui.notify(l);
62
+ if (lines.length === 1) {
63
+ ctx.ui.notify("[mega-compact] You own compaction + memory; no conflicting extensions detected.");
64
+ }
65
+ },
66
+ });
67
+
68
+ pi.registerCommand("mega-memory", {
69
+ description: "Save and recall durable memory in pi-mega-compact's SQLite store. Usage: /mega-memory save <text> | list | search <q> | recall <id>",
70
+ handler: async (args: string, ctx: ExtensionContext) => {
71
+ const repo = resolveRepoRoot(ctx.cwd) ?? runtime.currentStateDir;
72
+ const parts = args.trim().split(/\s+/);
73
+ const sub = parts[0]?.toLowerCase() ?? "list";
74
+
75
+ if (sub === "save") {
76
+ const text = args.trim().slice(4).trim();
77
+ if (!text) {
78
+ ctx.ui.notify("[mega-memory] usage: /mega-memory save <text>");
79
+ return;
80
+ }
81
+ // Optional "#tag #tag" parsing from the tail.
82
+ const tagMatches = [...text.matchAll(/#([\w-]+)/g)].map((m) => m[1]);
83
+ const content = text.replace(/#[\w-]+/g, "").trim();
84
+ const id = addMemory({ content, tags: tagMatches }, repo, runtime.currentStateDir);
85
+ ctx.ui.notify(`[mega-memory] saved #${id} to ${repo.split(/[\\/]/).pop()}`);
86
+ return;
87
+ }
88
+
89
+ if (sub === "search") {
90
+ const q = parts.slice(1).join(" ").trim();
91
+ if (!q) {
92
+ ctx.ui.notify("[mega-memory] usage: /mega-memory search <query>");
93
+ return;
94
+ }
95
+ const hits = searchMemories(q, repo, 50, runtime.currentStateDir);
96
+ if (!hits.length) {
97
+ ctx.ui.notify("[mega-memory] no memories match.");
98
+ return;
99
+ }
100
+ for (const m of hits) ctx.ui.notify(memoryLine(m));
101
+ return;
102
+ }
103
+
104
+ if (sub === "recall") {
105
+ const id = Number(parts[1]);
106
+ if (!Number.isFinite(id) || parts[1] === undefined) {
107
+ ctx.ui.notify("[mega-memory] usage: /mega-memory recall <id>");
108
+ return;
109
+ }
110
+ if (recallMemory(id, runtime.currentStateDir)) {
111
+ const found = listMemories(repo, 1000, runtime.currentStateDir).find((m) => m.id === id);
112
+ ctx.ui.notify(found ? `[mega-memory] ${memoryLine(found)}` : `[mega-memory] recalled #${id}`);
113
+ } else {
114
+ ctx.ui.notify(`[mega-memory] #${id} not found.`);
115
+ }
116
+ return;
117
+ }
118
+
119
+ // default: list
120
+ const all = listMemories(repo, 50, runtime.currentStateDir);
121
+ if (!all.length) {
122
+ ctx.ui.notify("[mega-memory] no saved memories yet. Use /mega-memory save <text>.");
123
+ return;
124
+ }
125
+ ctx.ui.notify(`[mega-memory] ${all.length} saved to ${repo.split(/[\\/]/).pop()}:`);
126
+ for (const m of all) ctx.ui.notify(memoryLine(m));
127
+ },
128
+ });
129
+ }
@@ -78,15 +78,13 @@ export function runCompact(
78
78
  // denominator (we don't want it pinned at 100% once we pass an old target).
79
79
  if (runtime.rt.tokensSaved > runtime.savedGoal) runtime.savedGoal = Math.ceil((runtime.rt.tokensSaved * 1.25) / 10_000) * 10_000;
80
80
 
81
- // Live toolbar "now processing" line: what file/region just got compacted or
82
- // deduped. Reset to the last-seen action after a few seconds (see snapshot).
81
+ // Live toolbar activity: what file/region just got compacted or deduped.
82
+ // Rendered via the rotating ticker line (see snapshot); the ring buffer is
83
+ // cycled one-per-repaint so the single line scrolls through recent files.
83
84
  const files = result.filesModified ?? [];
84
85
  const fileLabel = files.length
85
86
  ? files.map((f) => f.split("/").pop() ?? f).slice(0, 2).join(", ")
86
87
  : result.regionHash.slice(0, 8);
87
- runtime.currentActivity = result.deduped
88
- ? `♻ deduped ${fileLabel}`
89
- : `🗜 compacted ${result.checkpointId} · ${fileLabel}`;
90
88
  runtime.lastActivityAt = Date.now();
91
89
  // Explain-why line: surfaced while fresh. Pulls the dedup reason (which for
92
90
  // L2 includes the cosine sim) so the user sees WHY a region was kept/dropped.
@@ -90,10 +90,7 @@ export class MegaRuntime {
90
90
  // on model_select + session_start; persisted to SQL so cost + the dashboard
91
91
  // can read it without a live ctx.
92
92
  currentModel: ModelSnapshot | undefined;
93
- // Live "what it's doing right now" line for the toolbar. Set on each
94
- // compaction; shown in teal while recent, then kept as the last-seen action so
95
- // the widget is never blank. Cleared on session reset.
96
- currentActivity: string | undefined;
93
+ // Live "what it's doing right now" timestamp, used for the fresh-window.
97
94
  lastActivityAt = 0;
98
95
  // Live per-tier dedup trace (Phase 1): e.g. "L0 ✓ → L1 ✓ → L2 0.91 → stored".
99
96
  // Built from the store's sync onTier callback during a compaction so the user
@@ -269,25 +266,25 @@ export class MegaRuntime {
269
266
  const bar = "▓".repeat(filled) + "░".repeat(10 - filled);
270
267
  lines.push(` ${C.green}saved ${fmt(this.rt.tokensSaved)} ${bar}${C.reset} ${pct}% of ${fmt(goal)}`);
271
268
  }
272
- // Live "now processing" line teal while fresh (≤4s), then the last-seen
273
- // action keeps the widget lively. Cleared on session reset.
269
+ // Live "now processing" line + why + recent deduped/compacted events,
270
+ // collapsed to ONE rotating line (fresh only). The ticker ring buffer
271
+ // (≤5 most-recent events) is cycled one-per-repaint so the line scrolls
272
+ // through recent files in real time while activity fires. We rotate on a
273
+ // 250ms step (same cadence as the pulse), using an event counter as the
274
+ // deterministic phase so consecutive repaints advance the visible entry.
274
275
  const fresh = Date.now() - this.lastActivityAt < 4000;
275
276
  if (this.tierTrace && fresh) {
276
277
  lines.push(` ${pulse}${this.tierTrace}`);
277
- } else if (this.currentActivity) {
278
- lines.push(` ${fresh ? C.teal : C.dim}${this.currentActivity}${C.reset}`);
278
+ } else if (this.ticker.length > 0) {
279
+ const step = Math.floor(Date.now() / 250);
280
+ const idx = this.ticker.length - 1 - (step % this.ticker.length);
281
+ const head = this.ticker[idx].text;
282
+ const why = this.lastWhy ? ` ${C.gray}· ${this.lastWhy}${C.reset}` : "";
283
+ const more = this.ticker.length > 1 ? ` ${C.dim}(+${this.ticker.length - 1} more)${C.reset}` : "";
284
+ lines.push(` ${fresh ? C.teal : C.dim}${head}${why}${more}${C.reset}`);
279
285
  } else if (this.pulsing) {
280
286
  lines.push(` ${pulse}${C.teal}compacting…${C.reset}`);
281
287
  }
282
- // Phase 3 — explain-why line (fresh only).
283
- if (this.lastWhy && fresh) lines.push(` ${C.gray}${this.lastWhy}${C.reset}`);
284
- // Phase 3 — recall/activity ticker (most-recent first), fresh only.
285
- if (fresh) {
286
- for (let i = this.ticker.length - 1; i >= 0; i--) {
287
- if (lines.length >= 9) break; // leave room for the hint line (MAX 10)
288
- lines.push(` ${i === this.ticker.length - 1 ? "" : C.dim}${this.ticker[i].text}${C.reset}`);
289
- }
290
- }
291
288
  // Plain-language hint so first-time users understand the widget. Always
292
289
  // last, dimmed. "/mega-help explains these terms."
293
290
  if (lines.length < 10) {
@@ -318,7 +315,6 @@ export class MegaRuntime {
318
315
  this.statusKey = undefined;
319
316
  this.activeAgents = 0;
320
317
  this.currentTurn = 0;
321
- this.currentActivity = undefined;
322
318
  this.lastActivityAt = 0;
323
319
  this.tierTrace = undefined;
324
320
  this.ticker.length = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.4.20",
3
+ "version": "0.4.21",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-2-Clause",
@@ -415,6 +415,20 @@ function initSchema(db: Database.Database): void {
415
415
  ts INTEGER
416
416
  );
417
417
 
418
+ -- Durable "save to memory" store (taken over from memory extensions).
419
+ -- One row per saved memory; scoped by repo so memory travels with the
420
+ -- clone. All params are parameterized (PREVENT-002).
421
+ CREATE TABLE IF NOT EXISTS memories (
422
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
423
+ repo TEXT,
424
+ kind TEXT DEFAULT 'note', -- note | fact | decision | preference
425
+ content TEXT NOT NULL,
426
+ tags TEXT, -- JSON array of strings
427
+ created_at INTEGER,
428
+ last_recalled_at INTEGER
429
+ );
430
+ CREATE INDEX IF NOT EXISTS idx_memories_repo ON memories(repo);
431
+
418
432
  -- FTS5 trigram virtual table (Sprint 9+ pg_trgm-equivalent verification).
419
433
  CREATE VIRTUAL TABLE IF NOT EXISTS context_chunks_trgm USING fts5(
420
434
  id UNINDEXED,
@@ -582,6 +596,76 @@ export function addLesson(
582
596
  ).run(normalizeSessionId(sessionId), repo ?? null, lesson, now);
583
597
  }
584
598
 
599
+ // --- Durable memory (save-to-memory takeover) ---------------------------------
600
+ // One SQLite store for user-saved memories, scoped by repo. Mirrors the
601
+ // lessons/sessions pattern: all state lives in SQLite from day one.
602
+
603
+ export interface MemoryRecord {
604
+ id: number;
605
+ repo: string | null;
606
+ kind: string;
607
+ content: string;
608
+ tags: string[];
609
+ createdAt: number;
610
+ lastRecalledAt: number | null;
611
+ }
612
+
613
+ /** Save a memory to the current repo's store. Returns the new row id. */
614
+ export function addMemory(
615
+ memory: { kind?: string; content: string; tags?: string[] },
616
+ repo: string | null,
617
+ stateDir: string = getStateDir(),
618
+ ): number {
619
+ const db = openStore(stateDir);
620
+ const now = Math.floor(Date.now() / 1000);
621
+ const res = db
622
+ .prepare(
623
+ `INSERT INTO memories(repo, kind, content, tags, created_at, last_recalled_at)
624
+ VALUES(?, ?, ?, ?, ?, NULL)`,
625
+ )
626
+ .run(repo ?? null, memory.kind ?? "note", memory.content, JSON.stringify(memory.tags ?? []), now);
627
+ return Number(res.lastInsertRowid);
628
+ }
629
+
630
+ /** List recent memories for a repo (or all repos when repo is null). */
631
+ export function listMemories(repo: string | null, limit = 50, stateDir: string = getStateDir()): MemoryRecord[] {
632
+ const db = openStore(stateDir);
633
+ const rows = repo
634
+ ? db.prepare("SELECT * FROM memories WHERE repo = ? ORDER BY created_at DESC LIMIT ?").all(repo, limit)
635
+ : db.prepare("SELECT * FROM memories ORDER BY created_at DESC LIMIT ?").all(limit);
636
+ return (rows as any[]).map(mapMemoryRow);
637
+ }
638
+
639
+ /** Substring search across content + tags. */
640
+ export function searchMemories(query: string, repo: string | null = null, limit = 50, stateDir: string = getStateDir()): MemoryRecord[] {
641
+ const db = openStore(stateDir);
642
+ const like = `%${query}%`;
643
+ const rows = repo
644
+ ? db.prepare("SELECT * FROM memories WHERE repo = ? AND (content LIKE ? OR tags LIKE ?) ORDER BY created_at DESC LIMIT ?").all(repo, like, like, limit)
645
+ : db.prepare("SELECT * FROM memories WHERE content LIKE ? OR tags LIKE ? ORDER BY created_at DESC LIMIT ?").all(like, like, limit);
646
+ return (rows as any[]).map(mapMemoryRow);
647
+ }
648
+
649
+ /** Mark a memory as recalled (updates last_recalled_at). Returns true if found. */
650
+ export function recallMemory(id: number, stateDir: string = getStateDir()): boolean {
651
+ const db = openStore(stateDir);
652
+ const now = Math.floor(Date.now() / 1000);
653
+ const res = db.prepare("UPDATE memories SET last_recalled_at = ? WHERE id = ?").run(now, id);
654
+ return res.changes > 0;
655
+ }
656
+
657
+ function mapMemoryRow(row: any): MemoryRecord {
658
+ return {
659
+ id: row.id,
660
+ repo: row.repo ?? null,
661
+ kind: row.kind ?? "note",
662
+ content: row.content ?? "",
663
+ tags: row.tags ? JSON.parse(row.tags) : [],
664
+ createdAt: row.created_at ?? 0,
665
+ lastRecalledAt: row.last_recalled_at ?? null,
666
+ };
667
+ }
668
+
585
669
  /** Map a DB row to the public StoredCheckpoint shape. */
586
670
  function rowToCheckpoint(row: any): StoredCheckpoint {
587
671
  return {