pi-mega-compact 0.4.19 → 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.
@@ -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
+ }
@@ -14,7 +14,8 @@
14
14
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
15
15
  import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
16
16
  import { homedir } from "node:os";
17
- import { join } from "node:path";
17
+ import { join, dirname } from "node:path";
18
+ import { fileURLToPath } from "node:url";
18
19
  import Database from "better-sqlite3";
19
20
 
20
21
  // --- Multi-repo index (Phase 5b) ------------------------------------------------
@@ -61,7 +62,7 @@ function readIndex(): { updatedAt: string; summary: unknown; repos: unknown[] }
61
62
  const rows = db
62
63
  .prepare("SELECT * FROM repo_registry ORDER BY last_seen DESC")
63
64
  .all() as Record<string, unknown>[];
64
- const repos: IndexRepo[] = rows.map((r) => ({
65
+ const mapped: IndexRepo[] = rows.map((r) => ({
65
66
  repoRoot: String(r.repo_root ?? ""),
66
67
  displayName: String(r.display_name ?? ""),
67
68
  checkpointCount: Number(r.checkpoint_count ?? 0),
@@ -75,6 +76,22 @@ function readIndex(): { updatedAt: string; summary: unknown; repos: unknown[] }
75
76
  outputRate: (r.output_rate as number | null) ?? null,
76
77
  lastSeen: Number(r.last_seen ?? 0),
77
78
  }));
79
+ // Defensive display hygiene (belt-and-suspenders — the real fix is that
80
+ // tests now isolate via MEGACOMPACT_INDEX_DIR): drop transient test/temp
81
+ // paths that should never have been real repos, and collapse duplicate
82
+ // display names to the most-recently-seen row (rows are last_seen DESC, so
83
+ // the first occurrence wins). Keeps the All-repos list readable.
84
+ const isTransient = (p: string) =>
85
+ /^\/tmp\//.test(p) || /^\/private\/tmp\//.test(p) || /^\/var\/folders\//.test(p) ||
86
+ /\/mc-(ext|e2e|resume|recall)-/.test(p);
87
+ const seenName = new Set<string>();
88
+ const repos: IndexRepo[] = [];
89
+ for (const r of mapped) {
90
+ if (isTransient(r.repoRoot)) continue;
91
+ if (seenName.has(r.displayName)) continue;
92
+ seenName.add(r.displayName);
93
+ repos.push(r);
94
+ }
78
95
  const summary = {
79
96
  totalRepos: repos.length,
80
97
  totalCheckpoints: repos.reduce((a, r) => a + r.checkpointCount, 0),
@@ -722,6 +739,21 @@ function dashboardHtml(tierName: string): string {
722
739
  // ---------------------------------------------------------------------------
723
740
 
724
741
  export function launchDashboardServer(stateDir: string): Promise<{ port: number; url: string }> {
742
+ // Our own package version — exposed at /api/version so the launcher can
743
+ // detect a stale server (started by an older build) and replace it on
744
+ // upgrade instead of reuse it.
745
+ let SERVER_VERSION = "0.0.0";
746
+ try {
747
+ // dashboard-server.js lives at <pkg>/dist/extensions/, so package.json is
748
+ // two levels up. Guard each candidate so a dev-checkout layout still works.
749
+ const here = dirname(fileURLToPath(import.meta.url));
750
+ const candidates = [join(here, "..", "..", "package.json"), join(here, "..", "package.json")];
751
+ for (const p of candidates) {
752
+ if (!existsSync(p)) continue;
753
+ const pkg = JSON.parse(readFileSync(p, "utf-8"));
754
+ if (pkg.version) { SERVER_VERSION = pkg.version; break; }
755
+ }
756
+ } catch { /* non-fatal */ }
725
757
  const portFile = join(stateDir, "port.pid");
726
758
  const snapshotPath = join(stateDir, "dashboard.json");
727
759
  const eventsPath = join(stateDir, "events.log");
@@ -769,6 +801,14 @@ export function launchDashboardServer(stateDir: string): Promise<{ port: number;
769
801
  return;
770
802
  }
771
803
 
804
+ // Server version — lets the /dashboard launcher detect a stale server from
805
+ // an older build and replace it on upgrade rather than reuse it.
806
+ if (req.url === "/api/version") {
807
+ res.writeHead(200, { "Content-Type": "application/json" });
808
+ res.end(JSON.stringify({ version: SERVER_VERSION }));
809
+ return;
810
+ }
811
+
772
812
  // Multi-repo aggregate (Phase 5b): the machine-wide repo registry read
773
813
  // directly from SQLite (index.sqlite). Lets one dashboard show every repo's
774
814
  // checkpoints, tokens saved, and active model. Read-only.
@@ -22,6 +22,9 @@ import type { AgentMessage } from "@earendil-works/pi-agent-core";
22
22
 
23
23
  const require = createRequire(import.meta.url);
24
24
  const baseTmp = mkdtempSync(join(tmpdir(), "mc-ext-"));
25
+ // Isolate the machine-wide repo index so test runs (which call bindRepo ->
26
+ // upsertRepoRegistry) never pollute the developer's real ~/.mega-compact-index.
27
+ process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
25
28
  let counter = 0;
26
29
 
27
30
  /** Build a mock pi + ctx and load the extension into them. */
@@ -297,13 +300,14 @@ test("/dashboard-stop reports no server when pid file missing", async () => {
297
300
  test("/dashboard skips server spawn when already running", async () => {
298
301
  const h = harness();
299
302
  const confirms: boolean[] = [];
300
- // Set up a fake HTTP server at a random port
303
+ // Set up a fake HTTP server on a port inside the dashboard's scan range
304
+ // (9320–9329) — isServerRunning() probes those ports, not the port.pid value.
301
305
  const { createServer } = await import("node:http");
302
306
  const server = createServer((_req, res) => {
303
307
  res.writeHead(200, { "Content-Type": "application/json" });
304
308
  res.end(JSON.stringify({ updatedAt: new Date().toISOString(), tier: "test", version: 1, config: {}, session: {}, context: {}, trigger: {}, store: {} }));
305
309
  });
306
- await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
310
+ await new Promise<void>((r) => server.listen(9320, "127.0.0.1", r));
307
311
  const addr = server.address() as any;
308
312
  const { join: j } = await import("node:path");
309
313
  const { writeFileSync: wf } = await import("node:fs");
@@ -328,7 +332,8 @@ test("/dashboard skips server spawn when already running", async () => {
328
332
 
329
333
  test("/dashboard-status reports running after dashboard start", async () => {
330
334
  const h = harness();
331
- // Write a fake port.pid with a real port (use a server we control)
335
+ // Write a fake port.pid; the server must listen inside the scan range
336
+ // (9320–9329) or isServerRunning() won't detect it.
332
337
  const { createServer } = await import("node:http");
333
338
  const { join: j } = await import("node:path");
334
339
  const { writeFileSync: wf } = await import("node:fs");
@@ -336,7 +341,7 @@ test("/dashboard-status reports running after dashboard start", async () => {
336
341
  res.writeHead(200, { "Content-Type": "application/json" });
337
342
  res.end(JSON.stringify({ updatedAt: new Date().toISOString(), tier: "test" }));
338
343
  });
339
- await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
344
+ await new Promise<void>((r) => server.listen(9321, "127.0.0.1", r));
340
345
  const addr = server.address() as any;
341
346
  wf(j(h.stateDir, "port.pid"), JSON.stringify({ port: addr.port, pid: process.pid }));
342
347
 
@@ -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
+ }
@@ -36,8 +36,11 @@ export function registerDashboardCommands(pi: ExtensionAPI, runtime: MegaRuntime
36
36
  return null;
37
37
  }
38
38
 
39
- /** Try to reach a running dashboard server. Returns { port, url } or null. */
40
- async function isServerRunning(): Promise<{ port: number; url: string } | null> {
39
+ /** Try to reach a running dashboard server. Returns details or null.
40
+ * `hasPidFile` tells the caller whether this server was launched by us
41
+ * (port.pid present) — a live server with NO pid file is an orphan from an
42
+ * older/detached spawn that we should replace rather than reuse. */
43
+ async function isServerRunning(): Promise<{ port: number; url: string; hasPidFile: boolean } | null> {
41
44
  const port = await findLivePort();
42
45
  if (!port) {
43
46
  // Stale marker with no live server behind it — clean up.
@@ -46,7 +49,59 @@ export function registerDashboardCommands(pi: ExtensionAPI, runtime: MegaRuntime
46
49
  }
47
50
  return null;
48
51
  }
49
- return { port, url: `http://localhost:${port}` }; // guardrails-allow PREVENT-PI-004: localhost URL of the dashboard server this extension spawned
52
+ return { port, url: `http://localhost:${port}`, hasPidFile: existsSync(portFile) }; // guardrails-allow PREVENT-PI-004: localhost URL of the dashboard server this extension spawned
53
+ }
54
+
55
+ /** Version the running server on `port` reports, or null. */
56
+ async function serverVersion(port: number): Promise<string | null> {
57
+ try {
58
+ const res = await fetch(`http://localhost:${port}/api/version`, { signal: AbortSignal.timeout(800) }); // guardrails-allow PREVENT-PI-004: localhost version probe of the dashboard server this extension spawned
59
+ if (!res.ok) return null;
60
+ const j = await res.json() as { version?: string };
61
+ return j.version ?? null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /** Version of THIS extension (read from its own package.json). */
68
+ function ownVersion(): string | null {
69
+ try {
70
+ const here = dirname(fileURLToPath(import.meta.url)); // .../extensions
71
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
72
+ return pkg.version ?? null;
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ /** PID listening on 127.0.0.1:port (our own server), or null. Uses `ss`
79
+ * (Linux/macOS) — best-effort, returns null if unavailable. */
80
+ function pidOnPort(port: number): number | null {
81
+ try {
82
+ const { execSync } = require("node:child_process"); // guardrails-allow PREVENT-PI-004: localhost-only, reads our own dashboard port owner
83
+ const out = execSync(`ss -ltnp 2>/dev/null | grep ':${port} '`, { encoding: "utf-8" });
84
+ const m = out.match(/pid=(\d+)/);
85
+ return m ? Number(m[1]) : null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /** Kill a running dashboard server (best-effort): read the pid from port.pid,
92
+ * or — when there's no marker (an orphan) — from the port owner. Then remove
93
+ * the marker so the next spawn starts fresh. */
94
+ function killServerOnPort(port: number): void {
95
+ let pid: number | null = null;
96
+ try {
97
+ const info = JSON.parse(readFileSync(portFile, "utf-8"));
98
+ if (info && info.pid) pid = info.pid;
99
+ } catch { /* no marker */ }
100
+ if (pid == null) pid = pidOnPort(port); // orphan with no pid.pid
101
+ if (pid != null) {
102
+ try { process.kill(pid, "SIGTERM"); } catch { /* already gone */ }
103
+ }
104
+ try { unlinkSync(portFile); } catch { /* ignore */ }
50
105
  }
51
106
 
52
107
  /**
@@ -122,10 +177,29 @@ export function registerDashboardCommands(pi: ExtensionAPI, runtime: MegaRuntime
122
177
  let info = await isServerRunning();
123
178
 
124
179
  if (info) {
125
- ctx.ui.notify(`[mega-compact] dashboard already running at ${info.url}`);
126
- const open = await ctx.ui.confirm("mega-compact dashboard", `Open ${info.url} in browser?`);
127
- if (open) openBrowser(info.url);
128
- return;
180
+ // Replace the server when it's stale: either (a) an orphan a live
181
+ // server with no port.pid (e.g. left running from a detached spawn or a
182
+ // previous upgrade) that keeps serving old HTML from memory; or (b) a
183
+ // server that reports a different version than this extension (an older
184
+ // build). A live server WITH a matching pid file and version is reused.
185
+ const orphan = !info.hasPidFile;
186
+ const running = await serverVersion(info.port);
187
+ const want = ownVersion();
188
+ const stale = orphan || (want != null && running != null && running !== want);
189
+ if (stale) {
190
+ ctx.ui.notify(
191
+ orphan
192
+ ? "[mega-compact] replacing orphaned dashboard server…"
193
+ : `[mega-compact] replacing stale dashboard (${running} → ${want})…`,
194
+ );
195
+ killServerOnPort(info.port);
196
+ info = null;
197
+ } else {
198
+ ctx.ui.notify(`[mega-compact] dashboard already running at ${info.url}`);
199
+ const open = await ctx.ui.confirm("mega-compact dashboard", `Open ${info.url} in browser?`);
200
+ if (open) openBrowser(info.url);
201
+ return;
202
+ }
129
203
  }
130
204
 
131
205
  // Start the server
@@ -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.