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.
- package/dist/extensions/conflict-scan.js +201 -0
- package/dist/extensions/dashboard-server.js +47 -2
- package/dist/extensions/mega-compact.js +2 -0
- package/dist/extensions/mega-compact.test.js +9 -4
- package/dist/extensions/mega-conflict-cmds.js +121 -0
- package/dist/extensions/mega-dashboard-cmds.js +89 -7
- package/dist/extensions/mega-pipeline.js +3 -5
- package/dist/extensions/mega-runtime.js +14 -20
- package/dist/src/store/sqlite.js +59 -0
- package/extensions/conflict-scan.ts +209 -0
- package/extensions/dashboard-server.ts +42 -2
- package/extensions/mega-compact.test.ts +9 -4
- package/extensions/mega-compact.ts +2 -0
- package/extensions/mega-conflict-cmds.ts +129 -0
- package/extensions/mega-dashboard-cmds.ts +81 -7
- package/extensions/mega-pipeline.ts +3 -5
- package/extensions/mega-runtime.ts +14 -18
- package/package.json +1 -1
- package/src/store/sqlite.ts +84 -0
|
@@ -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
|
+
}
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
import { createServer } from "node:http";
|
|
14
14
|
import { existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
|
|
15
15
|
import { homedir } from "node:os";
|
|
16
|
-
import { join } from "node:path";
|
|
16
|
+
import { join, dirname } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
17
18
|
import Database from "better-sqlite3";
|
|
18
19
|
// --- Multi-repo index (Phase 5b) ------------------------------------------------
|
|
19
20
|
// The extension writes a machine-wide repo registry into a single SQLite DB
|
|
@@ -46,7 +47,7 @@ function readIndex() {
|
|
|
46
47
|
const rows = db
|
|
47
48
|
.prepare("SELECT * FROM repo_registry ORDER BY last_seen DESC")
|
|
48
49
|
.all();
|
|
49
|
-
const
|
|
50
|
+
const mapped = rows.map((r) => ({
|
|
50
51
|
repoRoot: String(r.repo_root ?? ""),
|
|
51
52
|
displayName: String(r.display_name ?? ""),
|
|
52
53
|
checkpointCount: Number(r.checkpoint_count ?? 0),
|
|
@@ -60,6 +61,23 @@ function readIndex() {
|
|
|
60
61
|
outputRate: r.output_rate ?? null,
|
|
61
62
|
lastSeen: Number(r.last_seen ?? 0),
|
|
62
63
|
}));
|
|
64
|
+
// Defensive display hygiene (belt-and-suspenders — the real fix is that
|
|
65
|
+
// tests now isolate via MEGACOMPACT_INDEX_DIR): drop transient test/temp
|
|
66
|
+
// paths that should never have been real repos, and collapse duplicate
|
|
67
|
+
// display names to the most-recently-seen row (rows are last_seen DESC, so
|
|
68
|
+
// the first occurrence wins). Keeps the All-repos list readable.
|
|
69
|
+
const isTransient = (p) => /^\/tmp\//.test(p) || /^\/private\/tmp\//.test(p) || /^\/var\/folders\//.test(p) ||
|
|
70
|
+
/\/mc-(ext|e2e|resume|recall)-/.test(p);
|
|
71
|
+
const seenName = new Set();
|
|
72
|
+
const repos = [];
|
|
73
|
+
for (const r of mapped) {
|
|
74
|
+
if (isTransient(r.repoRoot))
|
|
75
|
+
continue;
|
|
76
|
+
if (seenName.has(r.displayName))
|
|
77
|
+
continue;
|
|
78
|
+
seenName.add(r.displayName);
|
|
79
|
+
repos.push(r);
|
|
80
|
+
}
|
|
63
81
|
const summary = {
|
|
64
82
|
totalRepos: repos.length,
|
|
65
83
|
totalCheckpoints: repos.reduce((a, r) => a + r.checkpointCount, 0),
|
|
@@ -634,6 +652,26 @@ function dashboardHtml(tierName) {
|
|
|
634
652
|
// Server
|
|
635
653
|
// ---------------------------------------------------------------------------
|
|
636
654
|
export function launchDashboardServer(stateDir) {
|
|
655
|
+
// Our own package version — exposed at /api/version so the launcher can
|
|
656
|
+
// detect a stale server (started by an older build) and replace it on
|
|
657
|
+
// upgrade instead of reuse it.
|
|
658
|
+
let SERVER_VERSION = "0.0.0";
|
|
659
|
+
try {
|
|
660
|
+
// dashboard-server.js lives at <pkg>/dist/extensions/, so package.json is
|
|
661
|
+
// two levels up. Guard each candidate so a dev-checkout layout still works.
|
|
662
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
663
|
+
const candidates = [join(here, "..", "..", "package.json"), join(here, "..", "package.json")];
|
|
664
|
+
for (const p of candidates) {
|
|
665
|
+
if (!existsSync(p))
|
|
666
|
+
continue;
|
|
667
|
+
const pkg = JSON.parse(readFileSync(p, "utf-8"));
|
|
668
|
+
if (pkg.version) {
|
|
669
|
+
SERVER_VERSION = pkg.version;
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
catch { /* non-fatal */ }
|
|
637
675
|
const portFile = join(stateDir, "port.pid");
|
|
638
676
|
const snapshotPath = join(stateDir, "dashboard.json");
|
|
639
677
|
const eventsPath = join(stateDir, "events.log");
|
|
@@ -674,6 +712,13 @@ export function launchDashboardServer(stateDir) {
|
|
|
674
712
|
res.end(JSON.stringify(snap));
|
|
675
713
|
return;
|
|
676
714
|
}
|
|
715
|
+
// Server version — lets the /dashboard launcher detect a stale server from
|
|
716
|
+
// an older build and replace it on upgrade rather than reuse it.
|
|
717
|
+
if (req.url === "/api/version") {
|
|
718
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
719
|
+
res.end(JSON.stringify({ version: SERVER_VERSION }));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
677
722
|
// Multi-repo aggregate (Phase 5b): the machine-wide repo registry read
|
|
678
723
|
// directly from SQLite (index.sqlite). Lets one dashboard show every repo's
|
|
679
724
|
// checkpoints, tokens saved, and active model. Read-only.
|
|
@@ -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
|
}
|
|
@@ -19,6 +19,9 @@ import { join } from "node:path";
|
|
|
19
19
|
import { createRequire } from "node:module";
|
|
20
20
|
const require = createRequire(import.meta.url);
|
|
21
21
|
const baseTmp = mkdtempSync(join(tmpdir(), "mc-ext-"));
|
|
22
|
+
// Isolate the machine-wide repo index so test runs (which call bindRepo ->
|
|
23
|
+
// upsertRepoRegistry) never pollute the developer's real ~/.mega-compact-index.
|
|
24
|
+
process.env.MEGACOMPACT_INDEX_DIR = join(baseTmp, "index");
|
|
22
25
|
let counter = 0;
|
|
23
26
|
/** Build a mock pi + ctx and load the extension into them. */
|
|
24
27
|
function harness(opts = {}) {
|
|
@@ -268,13 +271,14 @@ test("/dashboard-stop reports no server when pid file missing", async () => {
|
|
|
268
271
|
test("/dashboard skips server spawn when already running", async () => {
|
|
269
272
|
const h = harness();
|
|
270
273
|
const confirms = [];
|
|
271
|
-
// Set up a fake HTTP server
|
|
274
|
+
// Set up a fake HTTP server on a port inside the dashboard's scan range
|
|
275
|
+
// (9320–9329) — isServerRunning() probes those ports, not the port.pid value.
|
|
272
276
|
const { createServer } = await import("node:http");
|
|
273
277
|
const server = createServer((_req, res) => {
|
|
274
278
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
275
279
|
res.end(JSON.stringify({ updatedAt: new Date().toISOString(), tier: "test", version: 1, config: {}, session: {}, context: {}, trigger: {}, store: {} }));
|
|
276
280
|
});
|
|
277
|
-
await new Promise((r) => server.listen(
|
|
281
|
+
await new Promise((r) => server.listen(9320, "127.0.0.1", r));
|
|
278
282
|
const addr = server.address();
|
|
279
283
|
const { join: j } = await import("node:path");
|
|
280
284
|
const { writeFileSync: wf } = await import("node:fs");
|
|
@@ -295,7 +299,8 @@ test("/dashboard skips server spawn when already running", async () => {
|
|
|
295
299
|
});
|
|
296
300
|
test("/dashboard-status reports running after dashboard start", async () => {
|
|
297
301
|
const h = harness();
|
|
298
|
-
// Write a fake port.pid
|
|
302
|
+
// Write a fake port.pid; the server must listen inside the scan range
|
|
303
|
+
// (9320–9329) or isServerRunning() won't detect it.
|
|
299
304
|
const { createServer } = await import("node:http");
|
|
300
305
|
const { join: j } = await import("node:path");
|
|
301
306
|
const { writeFileSync: wf } = await import("node:fs");
|
|
@@ -303,7 +308,7 @@ test("/dashboard-status reports running after dashboard start", async () => {
|
|
|
303
308
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
304
309
|
res.end(JSON.stringify({ updatedAt: new Date().toISOString(), tier: "test" }));
|
|
305
310
|
});
|
|
306
|
-
await new Promise((r) => server.listen(
|
|
311
|
+
await new Promise((r) => server.listen(9321, "127.0.0.1", r));
|
|
307
312
|
const addr = server.address();
|
|
308
313
|
wf(j(h.stateDir, "port.pid"), JSON.stringify({ port: addr.port, pid: process.pid }));
|
|
309
314
|
const ctx = h.ctx();
|
|
@@ -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
|
+
}
|
|
@@ -32,7 +32,10 @@ export function registerDashboardCommands(pi, runtime) {
|
|
|
32
32
|
}
|
|
33
33
|
return null;
|
|
34
34
|
}
|
|
35
|
-
/** Try to reach a running dashboard server. Returns
|
|
35
|
+
/** Try to reach a running dashboard server. Returns details or null.
|
|
36
|
+
* `hasPidFile` tells the caller whether this server was launched by us
|
|
37
|
+
* (port.pid present) — a live server with NO pid file is an orphan from an
|
|
38
|
+
* older/detached spawn that we should replace rather than reuse. */
|
|
36
39
|
async function isServerRunning() {
|
|
37
40
|
const port = await findLivePort();
|
|
38
41
|
if (!port) {
|
|
@@ -45,7 +48,68 @@ export function registerDashboardCommands(pi, runtime) {
|
|
|
45
48
|
}
|
|
46
49
|
return null;
|
|
47
50
|
}
|
|
48
|
-
return { port, url: `http://localhost:${port}
|
|
51
|
+
return { port, url: `http://localhost:${port}`, hasPidFile: existsSync(portFile) }; // guardrails-allow PREVENT-PI-004: localhost URL of the dashboard server this extension spawned
|
|
52
|
+
}
|
|
53
|
+
/** Version the running server on `port` reports, or null. */
|
|
54
|
+
async function serverVersion(port) {
|
|
55
|
+
try {
|
|
56
|
+
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
|
|
57
|
+
if (!res.ok)
|
|
58
|
+
return null;
|
|
59
|
+
const j = await res.json();
|
|
60
|
+
return j.version ?? null;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Version of THIS extension (read from its own package.json). */
|
|
67
|
+
function ownVersion() {
|
|
68
|
+
try {
|
|
69
|
+
const here = dirname(fileURLToPath(import.meta.url)); // .../extensions
|
|
70
|
+
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf-8"));
|
|
71
|
+
return pkg.version ?? null;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** PID listening on 127.0.0.1:port (our own server), or null. Uses `ss`
|
|
78
|
+
* (Linux/macOS) — best-effort, returns null if unavailable. */
|
|
79
|
+
function pidOnPort(port) {
|
|
80
|
+
try {
|
|
81
|
+
const { execSync } = require("node:child_process"); // guardrails-allow PREVENT-PI-004: localhost-only, reads our own dashboard port owner
|
|
82
|
+
const out = execSync(`ss -ltnp 2>/dev/null | grep ':${port} '`, { encoding: "utf-8" });
|
|
83
|
+
const m = out.match(/pid=(\d+)/);
|
|
84
|
+
return m ? Number(m[1]) : null;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** Kill a running dashboard server (best-effort): read the pid from port.pid,
|
|
91
|
+
* or — when there's no marker (an orphan) — from the port owner. Then remove
|
|
92
|
+
* the marker so the next spawn starts fresh. */
|
|
93
|
+
function killServerOnPort(port) {
|
|
94
|
+
let pid = null;
|
|
95
|
+
try {
|
|
96
|
+
const info = JSON.parse(readFileSync(portFile, "utf-8"));
|
|
97
|
+
if (info && info.pid)
|
|
98
|
+
pid = info.pid;
|
|
99
|
+
}
|
|
100
|
+
catch { /* no marker */ }
|
|
101
|
+
if (pid == null)
|
|
102
|
+
pid = pidOnPort(port); // orphan with no pid.pid
|
|
103
|
+
if (pid != null) {
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, "SIGTERM");
|
|
106
|
+
}
|
|
107
|
+
catch { /* already gone */ }
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
unlinkSync(portFile);
|
|
111
|
+
}
|
|
112
|
+
catch { /* ignore */ }
|
|
49
113
|
}
|
|
50
114
|
/**
|
|
51
115
|
* Resolve the launchable dashboard-server module.
|
|
@@ -119,11 +183,29 @@ export function registerDashboardCommands(pi, runtime) {
|
|
|
119
183
|
runtime.bindRepo(ctx.cwd);
|
|
120
184
|
let info = await isServerRunning();
|
|
121
185
|
if (info) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
186
|
+
// Replace the server when it's stale: either (a) an orphan — a live
|
|
187
|
+
// server with no port.pid (e.g. left running from a detached spawn or a
|
|
188
|
+
// previous upgrade) that keeps serving old HTML from memory; or (b) a
|
|
189
|
+
// server that reports a different version than this extension (an older
|
|
190
|
+
// build). A live server WITH a matching pid file and version is reused.
|
|
191
|
+
const orphan = !info.hasPidFile;
|
|
192
|
+
const running = await serverVersion(info.port);
|
|
193
|
+
const want = ownVersion();
|
|
194
|
+
const stale = orphan || (want != null && running != null && running !== want);
|
|
195
|
+
if (stale) {
|
|
196
|
+
ctx.ui.notify(orphan
|
|
197
|
+
? "[mega-compact] replacing orphaned dashboard server…"
|
|
198
|
+
: `[mega-compact] replacing stale dashboard (${running} → ${want})…`);
|
|
199
|
+
killServerOnPort(info.port);
|
|
200
|
+
info = null;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
ctx.ui.notify(`[mega-compact] dashboard already running at ${info.url}`);
|
|
204
|
+
const open = await ctx.ui.confirm("mega-compact dashboard", `Open ${info.url} in browser?`);
|
|
205
|
+
if (open)
|
|
206
|
+
openBrowser(info.url);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
127
209
|
}
|
|
128
210
|
// Start the server
|
|
129
211
|
ctx.ui.notify("[mega-compact] starting dashboard server…");
|
|
@@ -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
|
|
60
|
-
//
|
|
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"
|
|
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
|
|
247
|
-
//
|
|
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.
|
|
253
|
-
|
|
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;
|