pi-mega-compact 0.6.3 → 0.6.5
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 +133 -60
- package/dist/extensions/dashboard-server.js +161 -35
- package/dist/extensions/dashboard-server.test.js +2 -2
- package/dist/extensions/mega-runtime.js +51 -23
- package/dist/src/compact.js +7 -1
- package/dist/src/supersede.js +3 -0
- package/extensions/conflict-scan.test.ts +129 -0
- package/extensions/conflict-scan.ts +243 -158
- package/extensions/dashboard-server.test.ts +2 -2
- package/extensions/dashboard-server.ts +166 -36
- package/extensions/mega-dashboard.ts +16 -0
- package/extensions/mega-runtime.ts +52 -23
- package/package.json +1 -1
- package/src/compact.ts +7 -2
- package/src/supersede.ts +3 -0
|
@@ -12,10 +12,18 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Pi-agnostic: reads package.json + greps source. No pi runtime types, so it is
|
|
14
14
|
* unit-testable against a fixture node_modules tree.
|
|
15
|
+
*
|
|
16
|
+
* SCAN-SCOPE FIX (S24 follow-up): the original scanner only walked the npm
|
|
17
|
+
* `node_modules` tree, so user-level extensions installed outside npm (e.g.
|
|
18
|
+
* `pi-hermes-memory`, a data-only `MEMORY.md` + `sessions.db` memory store)
|
|
19
|
+
* were never inspected — that gap let the 5000-char file-buffer error slip
|
|
20
|
+
* through undetected. We now also scan the user-level extension dir and detect
|
|
21
|
+
* memory stores that ship with no package.json / source to grep.
|
|
15
22
|
*/
|
|
16
23
|
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
17
24
|
import { join, dirname } from "node:path";
|
|
18
25
|
import { fileURLToPath } from "node:url";
|
|
26
|
+
import { homedir } from "node:os";
|
|
19
27
|
// Marker sets. A package is flagged when its source matches a marker in a
|
|
20
28
|
// category. File-grep (not AST) keeps this dependency-free and fast.
|
|
21
29
|
const MARKERS = {
|
|
@@ -38,10 +46,7 @@ const MARKERS = {
|
|
|
38
46
|
"memoryTool",
|
|
39
47
|
],
|
|
40
48
|
// Tool-output shaping (compact/summarize tool results) — overlap, not a rival.
|
|
41
|
-
toolOutput: [
|
|
42
|
-
"tool_result",
|
|
43
|
-
"ToolResult",
|
|
44
|
-
],
|
|
49
|
+
toolOutput: ["tool_result", "ToolResult"],
|
|
45
50
|
};
|
|
46
51
|
/** Resolve the node_modules dir that contains this package (or env override). */
|
|
47
52
|
export function resolveExtensionRoot(selfDir = dirname(fileURLToPath(import.meta.url))) {
|
|
@@ -62,6 +67,41 @@ export function resolveExtensionRoot(selfDir = dirname(fileURLToPath(import.meta
|
|
|
62
67
|
}
|
|
63
68
|
return null;
|
|
64
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolve every directory that may hold pi extensions to scan.
|
|
72
|
+
*
|
|
73
|
+
* - `MEGACOMPACT_EXT_SCAN_DIR` (if set) replaces the whole list — a single
|
|
74
|
+
* fixture/override root for tests or custom layouts.
|
|
75
|
+
* - Otherwise: the node_modules that holds this package (classic npm layout) AND
|
|
76
|
+
* the user-level extension dir (`~/.pi/agent`), which is where extensions
|
|
77
|
+
* installed outside npm actually live. The original scanner only walked
|
|
78
|
+
* node_modules, so user-level memory extensions were never flagged — that is
|
|
79
|
+
* the gap that let the 5000-char `MEMORY.md` buffer error slip through.
|
|
80
|
+
*/
|
|
81
|
+
export function collectScanRoots() {
|
|
82
|
+
const override = process.env.MEGACOMPACT_EXT_SCAN_DIR;
|
|
83
|
+
if (override && override.trim() !== "")
|
|
84
|
+
return [override];
|
|
85
|
+
const roots = [];
|
|
86
|
+
const nm = resolveExtensionRoot();
|
|
87
|
+
if (nm && existsSync(nm))
|
|
88
|
+
roots.push(nm);
|
|
89
|
+
const userDir = process.env.MEGACOMPACT_EXT_USER_DIR?.trim() ||
|
|
90
|
+
join(homedir(), ".pi", "agent");
|
|
91
|
+
if (existsSync(userDir))
|
|
92
|
+
roots.push(userDir);
|
|
93
|
+
return roots;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* True when a directory is a pi memory-store container rather than a normal code
|
|
97
|
+
* extension. `pi-hermes-memory` ships as exactly this: no package.json, no
|
|
98
|
+
* source — just `MEMORY.md` + `sessions.db`. The marker-grep path misses it,
|
|
99
|
+
* so we also detect the on-disk memory signature.
|
|
100
|
+
*/
|
|
101
|
+
function isMemoryStoreDir(pkgDir) {
|
|
102
|
+
return (existsSync(join(pkgDir, "sessions.db")) ||
|
|
103
|
+
existsSync(join(pkgDir, "MEMORY.md")));
|
|
104
|
+
}
|
|
65
105
|
/** Recursively collect source-ish files under a package, capped to avoid scans. */
|
|
66
106
|
function collectFiles(root, max = 400) {
|
|
67
107
|
const out = [];
|
|
@@ -131,70 +171,103 @@ function matchMarkers(pkgDir, keys) {
|
|
|
131
171
|
* @param selfName package name to skip (defaults to this package's name).
|
|
132
172
|
*/
|
|
133
173
|
export function detectConflicts(selfName = "pi-mega-compact") {
|
|
134
|
-
const
|
|
174
|
+
const roots = collectScanRoots();
|
|
135
175
|
const scanned = [];
|
|
136
176
|
const conflicts = [];
|
|
137
|
-
|
|
138
|
-
|
|
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))
|
|
177
|
+
for (const root of roots) {
|
|
178
|
+
if (!existsSync(root))
|
|
152
179
|
continue;
|
|
153
|
-
let
|
|
180
|
+
let entries;
|
|
154
181
|
try {
|
|
155
|
-
|
|
182
|
+
entries = readdirSync(root);
|
|
156
183
|
}
|
|
157
184
|
catch {
|
|
158
185
|
continue;
|
|
159
186
|
}
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
187
|
+
for (const name of entries) {
|
|
188
|
+
const pkgDir = join(root, name);
|
|
189
|
+
let st;
|
|
190
|
+
try {
|
|
191
|
+
st = statSync(pkgDir);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (!st.isDirectory())
|
|
197
|
+
continue;
|
|
198
|
+
// A candidate is either a real code extension (declares pi.extensions) or a
|
|
199
|
+
// data-only memory store (MEMORY.md / sessions.db at its root).
|
|
200
|
+
const pkgJson = join(pkgDir, "package.json");
|
|
201
|
+
let pkg = null;
|
|
202
|
+
if (existsSync(pkgJson)) {
|
|
203
|
+
try {
|
|
204
|
+
pkg = JSON.parse(readFileSync(pkgJson, "utf-8"));
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
pkg = null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const isCodeExt = !!pkg &&
|
|
211
|
+
!!pkg.pi &&
|
|
212
|
+
Array.isArray(pkg.pi.extensions) &&
|
|
213
|
+
pkg.pi.extensions.length > 0;
|
|
214
|
+
const isMemoryStore = isMemoryStoreDir(pkgDir);
|
|
215
|
+
if (!isCodeExt && !isMemoryStore)
|
|
216
|
+
continue;
|
|
217
|
+
const pkgName = pkg?.name ?? name;
|
|
218
|
+
if (pkgName === selfName)
|
|
219
|
+
continue;
|
|
220
|
+
scanned.push(`${pkgName} (${name})`);
|
|
221
|
+
if (isCodeExt) {
|
|
222
|
+
const memHits = matchMarkers(pkgDir, MARKERS.memory);
|
|
223
|
+
const compHits = matchMarkers(pkgDir, MARKERS.compaction);
|
|
224
|
+
const toolHits = matchMarkers(pkgDir, MARKERS.toolOutput);
|
|
225
|
+
if (compHits.length > 0) {
|
|
226
|
+
conflicts.push({
|
|
227
|
+
package: pkgName,
|
|
228
|
+
severity: "high",
|
|
229
|
+
kind: "compaction",
|
|
230
|
+
evidence: compHits,
|
|
231
|
+
recommendation: "Disabling recommended — competes with pi-mega-compact's conversation compaction.",
|
|
232
|
+
});
|
|
233
|
+
continue; // compaction is the dominant conflict; don't double-flag.
|
|
234
|
+
}
|
|
235
|
+
if (memHits.length > 0) {
|
|
236
|
+
conflicts.push({
|
|
237
|
+
package: pkgName,
|
|
238
|
+
severity: "high",
|
|
239
|
+
kind: "memory",
|
|
240
|
+
evidence: memHits,
|
|
241
|
+
recommendation: "pi-mega-compact now owns save-to-memory (/mega-memory, its own SQLite). Disable this to avoid duplicate memory stores.",
|
|
242
|
+
});
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (toolHits.length > 0) {
|
|
246
|
+
conflicts.push({
|
|
247
|
+
package: pkgName,
|
|
248
|
+
severity: "info",
|
|
249
|
+
kind: "tool-output",
|
|
250
|
+
evidence: toolHits,
|
|
251
|
+
recommendation: "Shapes tool output (summarize/compact tool results). Generally compatible; no action needed.",
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
// Data-only memory store: no source to grep, but the on-disk signature
|
|
256
|
+
// (sessions.db / MEMORY.md) is a conflict with our SQLite memory store.
|
|
257
|
+
if (isMemoryStore) {
|
|
258
|
+
const evidence = [];
|
|
259
|
+
if (existsSync(join(pkgDir, "sessions.db")))
|
|
260
|
+
evidence.push("sessions.db");
|
|
261
|
+
if (existsSync(join(pkgDir, "MEMORY.md")))
|
|
262
|
+
evidence.push("MEMORY.md");
|
|
263
|
+
conflicts.push({
|
|
264
|
+
package: pkgName,
|
|
265
|
+
severity: "high",
|
|
266
|
+
kind: "memory",
|
|
267
|
+
evidence,
|
|
268
|
+
recommendation: "pi-mega-compact now owns save-to-memory (its own SQLite). This data-only memory store competes with it — disable to avoid a duplicate / capped memory buffer.",
|
|
269
|
+
});
|
|
270
|
+
}
|
|
198
271
|
}
|
|
199
272
|
}
|
|
200
273
|
return { scanned, conflicts };
|
|
@@ -72,6 +72,7 @@ function readIndex() {
|
|
|
72
72
|
const mapped = rows.map((r) => ({
|
|
73
73
|
repoRoot: String(r.repo_root ?? ""),
|
|
74
74
|
displayName: String(r.display_name ?? ""),
|
|
75
|
+
stateDir: String(r.state_dir ?? ""),
|
|
75
76
|
checkpointCount: Number(r.checkpoint_count ?? 0),
|
|
76
77
|
tokensSaved: Number(r.tokens_saved ?? 0),
|
|
77
78
|
compressedOriginalBytes: Number(r.compressed_original_bytes ?? 0),
|
|
@@ -290,27 +291,31 @@ function dashboardHtml(tierName) {
|
|
|
290
291
|
<h2>Vector Store</h2>
|
|
291
292
|
<div class="stat-grid">
|
|
292
293
|
<span class="label" title="A saved summary of a chunk of your conversation that was compacted to free up space.">Checkpoints</span><span class="value" id="st-count">0</span>
|
|
293
|
-
<span class="label" title="
|
|
294
|
-
<span class="label" title="
|
|
295
|
-
<span class="label" title="
|
|
294
|
+
<span class="label" title="Total size of the original conversation text dropped into compaction this session, including redundant regions skipped by dedup. This is the 'in'.">Original (dropped)</span><span class="value" id="st-in">0</span>
|
|
295
|
+
<span class="label" title="Compact summaries we are currently holding as 'memory' for this session (the 'out'). Smaller is better.">Kept (summaries)</span><span class="value" id="st-kept">0</span>
|
|
296
|
+
<span class="label" title="Conversation space freed = dropped − kept (the 'saved').">Freed (dropped − kept)</span><span class="value" id="st-freed">0</span>
|
|
296
297
|
<span class="label" title="How many times old context was automatically brought back into the conversation because it was relevant to what you were doing.">Injected</span><span class="value" id="st-injected">0</span>
|
|
297
298
|
<span class="label" title="Of the times we recalled old context, how often it was actually on-topic.">Recall Relevance</span><span class="value" id="st-dedup">0%</span>
|
|
298
299
|
<span class="label" title="How often new content matched something we already had, so we skipped storing a duplicate copy. Higher = less wasted space.">Storage Dedup</span><span class="value" id="st-sdedup">0%</span>
|
|
299
300
|
<span class="label" title="How many duplicate chunks we collapsed into one instead of storing separately.">Collapsed</span><span class="value" id="st-collapsed">0</span>
|
|
300
301
|
<span class="label" title="The ID of the most recent saved checkpoint.">Last ID</span><span class="value" id="st-lastid">—</span>
|
|
301
302
|
</div>
|
|
303
|
+
<div class="meter-track" style="margin-top:10px"><div class="meter-fill" id="st-compress-bar" style="width:0%"></div></div>
|
|
304
|
+
<div class="meter-sub" id="st-compress-sub">waiting for compaction…</div>
|
|
302
305
|
</div>
|
|
303
306
|
<div class="card">
|
|
304
307
|
<h2>Repo (all sessions)</h2>
|
|
305
308
|
<div class="stat-grid">
|
|
306
309
|
<span class="label">Checkpoints</span><span class="value" id="rp-count">0</span>
|
|
307
|
-
<span class="label">
|
|
308
|
-
<span class="label">
|
|
309
|
-
<span class="label">
|
|
310
|
+
<span class="label">Original (dropped)</span><span class="value" id="rp-in">0</span>
|
|
311
|
+
<span class="label">Kept (summaries)</span><span class="value" id="rp-kept">0</span>
|
|
312
|
+
<span class="label">Freed (dropped − kept)</span><span class="value" id="rp-freed">0</span>
|
|
310
313
|
<span class="label">Sessions</span><span class="value" id="rp-sessions">0</span>
|
|
311
314
|
<span class="label">Collapsed</span><span class="value" id="rp-collapsed">0</span>
|
|
312
315
|
<span class="label">Storage Dedup</span><span class="value" id="rp-sdedup">0%</span>
|
|
313
316
|
</div>
|
|
317
|
+
<div class="meter-track" style="margin-top:10px"><div class="meter-fill" id="rp-compress-bar" style="width:0%"></div></div>
|
|
318
|
+
<div class="meter-sub" id="rp-compress-sub">waiting for compaction…</div>
|
|
314
319
|
</div>
|
|
315
320
|
<div class="card safe">
|
|
316
321
|
<h2>🛡 Data Safety</h2>
|
|
@@ -356,11 +361,11 @@ function dashboardHtml(tierName) {
|
|
|
356
361
|
<div class="card legend">
|
|
357
362
|
<h2>What these numbers mean</h2>
|
|
358
363
|
<ul class="legend-list">
|
|
359
|
-
<li><b>
|
|
360
|
-
<li><b>
|
|
361
|
-
<li><b>
|
|
362
|
-
<li><b>
|
|
363
|
-
<li><b>Storage dedup
|
|
364
|
+
<li><b>Original (dropped)</b> — everything compacted away (including duplicates caught by dedup). The "in."</li>
|
|
365
|
+
<li><b>Kept (summaries)</b> — compact summaries still held as "memory" (the "out").</li>
|
|
366
|
+
<li><b>Freed</b> = dropped − kept — tokens saved so far (higher = better).</li>
|
|
367
|
+
<li><b>Compression %</b> — Freed ÷ Dropped — the headline efficiency number. Higher = more space reclaimed.</li>
|
|
368
|
+
<li><b>Storage dedup %</b> — how often new content matched something already saved, so no duplicate copy was written.</li>
|
|
364
369
|
<li><b>Data safety</b> — every compacted region is kept verbatim (compressed). Nothing is permanently deleted; you can restore any of it.</li>
|
|
365
370
|
</ul>
|
|
366
371
|
<p class="legend-note">Hover any label above for a quick explanation.</p>
|
|
@@ -431,6 +436,23 @@ function dashboardHtml(tierName) {
|
|
|
431
436
|
<div class="summary-card"><div class="num" id="sm-saved">0</div><div class="lbl">Total Tokens Saved</div></div>
|
|
432
437
|
<div class="summary-card"><div class="num" id="sm-bytes">0 B</div><div class="lbl">Compressed-Original</div></div>
|
|
433
438
|
</div>
|
|
439
|
+
|
|
440
|
+
<h2 style="margin-top:24px;font-size:13px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px">Savings by Model</h2>
|
|
441
|
+
<p class="legend-note" style="margin-bottom:10px">How much context & cost mega-compact has reclaimed, grouped by the model you were running. Compression ratio reflects workload/content, not model quality.</p>
|
|
442
|
+
<table class="repos">
|
|
443
|
+
<thead>
|
|
444
|
+
<tr>
|
|
445
|
+
<th>Model</th><th>Provider</th>
|
|
446
|
+
<th style="text-align:right">Repos</th>
|
|
447
|
+
<th style="text-align:right">Checkpoints</th>
|
|
448
|
+
<th style="text-align:right">Tokens Saved</th>
|
|
449
|
+
<th style="text-align:right">$ Saved</th>
|
|
450
|
+
<th style="text-align:right">Last Used</th>
|
|
451
|
+
</tr>
|
|
452
|
+
</thead>
|
|
453
|
+
<tbody id="bm-rows"><tr><td colspan="7" class="repo-none">loading…</td></tr></tbody>
|
|
454
|
+
</table>
|
|
455
|
+
|
|
434
456
|
<div class="updated" id="sm-updated"></div>
|
|
435
457
|
</div>
|
|
436
458
|
|
|
@@ -464,10 +486,20 @@ function dashboardHtml(tierName) {
|
|
|
464
486
|
d.trigger.armed ? 'past fast gate — monitoring token count' : 'idle — below fast gate';
|
|
465
487
|
document.getElementById('tr-state').textContent = state;
|
|
466
488
|
|
|
489
|
+
// ---- Vector Store — reconciled token accounting (same formula as widget) -
|
|
467
490
|
document.getElementById('st-count').textContent = d.store.checkpointCount;
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
491
|
+
// Compression block from the snapshot (Freed = In − Out, single formula).
|
|
492
|
+
var c = d.compression || {};
|
|
493
|
+
var sess = c.session || { tokensIn:0, tokensOut:0, tokensFreed:0, compressionPct:0, dedupPct:0 };
|
|
494
|
+
var cRepo = c.repo || { tokensIn:0, tokensOut:0, tokensFreed:0, compressionPct:0, dedupPct:0 };
|
|
495
|
+
document.getElementById('st-in').textContent = sess.tokensIn.toLocaleString();
|
|
496
|
+
document.getElementById('st-kept').textContent = sess.tokensOut.toLocaleString();
|
|
497
|
+
document.getElementById('st-freed').textContent = sess.tokensFreed.toLocaleString();
|
|
498
|
+
var sp = sess.compressionPct || 0;
|
|
499
|
+
document.getElementById('st-compress-bar').style.width = Math.max(sp * 100, 0.5) + '%';
|
|
500
|
+
document.getElementById('st-compress-bar').className = 'meter-fill ' + (sp >= 0.9 ? 'meter-green' : sp >= 0.6 ? 'meter-yellow' : 'meter-red');
|
|
501
|
+
document.getElementById('st-compress-sub').textContent = (sp * 100 >= 10 ? Math.round(sp * 100) : (sp * 100).toFixed(1)) + '% tokens saved · dedup: ' + (sess.dedupPct * 100 >= 10 ? Math.round(sess.dedupPct * 100) : (sess.dedupPct * 100).toFixed(1)) + '%';
|
|
502
|
+
// ------
|
|
471
503
|
document.getElementById('st-injected').textContent = d.store.injectedCount;
|
|
472
504
|
document.getElementById('st-dedup').textContent = Math.round(d.store.dedupHitRate * 100) + '%';
|
|
473
505
|
var sdr = d.store.storageDedupRate || 0;
|
|
@@ -475,16 +507,19 @@ function dashboardHtml(tierName) {
|
|
|
475
507
|
document.getElementById('st-collapsed').textContent = d.store.dedupCollapsed || 0;
|
|
476
508
|
document.getElementById('st-lastid').textContent = d.session.lastCheckpointId || '—';
|
|
477
509
|
|
|
478
|
-
// Repo
|
|
479
|
-
|
|
480
|
-
document.getElementById('rp-
|
|
481
|
-
document.getElementById('rp-
|
|
482
|
-
document.getElementById('rp-
|
|
483
|
-
document.getElementById('rp-
|
|
484
|
-
document.getElementById('rp-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
510
|
+
// ---- Repo (all sessions) — same compression fields, repo scope ----------
|
|
511
|
+
document.getElementById('rp-count').textContent = (d.repo && d.repo.checkpointCount || 0).toLocaleString();
|
|
512
|
+
document.getElementById('rp-in').textContent = cRepo.tokensIn.toLocaleString();
|
|
513
|
+
document.getElementById('rp-kept').textContent = cRepo.tokensOut.toLocaleString();
|
|
514
|
+
document.getElementById('rp-freed').textContent = cRepo.tokensFreed.toLocaleString();
|
|
515
|
+
document.getElementById('rp-sessions').textContent = (d.repo && d.repo.sessionCount || 0).toLocaleString();
|
|
516
|
+
document.getElementById('rp-collapsed').textContent = (d.repo && d.repo.dedupCollapsed || 0).toLocaleString();
|
|
517
|
+
var rdr = d.repo && d.repo.storageDedupRate || 0;
|
|
518
|
+
document.getElementById('rp-sdedup').textContent = (rdr * 100 >= 10 ? Math.round(rdr * 100) : (rdr * 100).toFixed(1)) + '%';
|
|
519
|
+
var rp = cRepo.compressionPct || 0;
|
|
520
|
+
document.getElementById('rp-compress-bar').style.width = Math.max(rp * 100, 0.5) + '%';
|
|
521
|
+
document.getElementById('rp-compress-bar').className = 'meter-fill ' + (rp >= 0.9 ? 'meter-green' : rp >= 0.6 ? 'meter-yellow' : 'meter-red');
|
|
522
|
+
document.getElementById('rp-compress-sub').textContent = (rp * 100 >= 10 ? Math.round(rp * 100) : (rp * 100).toFixed(1)) + '% tokens saved · dedup: ' + (cRepo.dedupPct * 100 >= 10 ? Math.round(cRepo.dedupPct * 100) : (cRepo.dedupPct * 100).toFixed(1)) + '%';
|
|
488
523
|
|
|
489
524
|
// Data-safety invariant (Phase 0 — trust foundation).
|
|
490
525
|
var ig = d.integrity || { regionsRetained: 0, compressedOriginalBytes: 0, duplicatesCollapsed: 0, bytesPermanentlyDeleted: 0 };
|
|
@@ -524,10 +559,11 @@ function dashboardHtml(tierName) {
|
|
|
524
559
|
document.getElementById('md-provider').textContent = model && model.providerName ? model.providerName : (model && model.provider ? model.provider : '—');
|
|
525
560
|
document.getElementById('md-input').textContent = model && model.inputRate ? '$' + (model.inputRate).toFixed(6) : '—';
|
|
526
561
|
document.getElementById('md-output').textContent = model && model.outputRate ? '$' + (model.outputRate).toFixed(6) : '—';
|
|
527
|
-
|
|
528
|
-
|
|
562
|
+
var repoSaved = cRepo.tokensFreed || 0;
|
|
563
|
+
if (model && model.inputRate && repoSaved > 0) {
|
|
564
|
+
var usd = (repoSaved * model.inputRate);
|
|
529
565
|
var win = d.context.contextWindow || 0;
|
|
530
|
-
var windows = win > 0 ? (
|
|
566
|
+
var windows = win > 0 ? (repoSaved / win).toFixed(1) : '0';
|
|
531
567
|
document.getElementById('cost-usd').textContent = '≈ $' + usd.toFixed(4) + ' saved';
|
|
532
568
|
document.getElementById('cost-windows').textContent = windows + ' context-windows extended';
|
|
533
569
|
} else {
|
|
@@ -625,6 +661,49 @@ function dashboardHtml(tierName) {
|
|
|
625
661
|
document.getElementById('cur-updated').textContent = stamp;
|
|
626
662
|
document.getElementById('all-updated').textContent = stamp;
|
|
627
663
|
document.getElementById('sm-updated').textContent = stamp;
|
|
664
|
+
renderByModel(repos);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Savings-by-model aggregation for the Summary tab — groups the machine-
|
|
668
|
+
// wide repo registry by (modelName || '(unknown)') so the user can see how
|
|
669
|
+
// much context + cost mega-compact has reclaimed, broken down by which model
|
|
670
|
+
// they were running. $ Saved = Σ(tokensSaved × inputRate) per model. Sorted
|
|
671
|
+
// by tokens saved descending so the biggest-reclaim model wins the top row.
|
|
672
|
+
function renderByModel(repos) {
|
|
673
|
+
var rows = document.getElementById('bm-rows');
|
|
674
|
+
if (!rows) return;
|
|
675
|
+
if (!repos || !repos.length) {
|
|
676
|
+
rows.innerHTML = '<tr><td colspan="7" class="repo-none">No repositories registered yet.</td></tr>';
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
var groups = {};
|
|
680
|
+
for (var i = 0; i < repos.length; i++) {
|
|
681
|
+
var r = repos[i];
|
|
682
|
+
var key = (r.modelName && String(r.modelName).trim()) || '(unknown)';
|
|
683
|
+
if (!groups[key]) groups[key] = { model: key, provider: r.providerName || r.provider || '—', repos: 0, checkpoints: 0, tokensSaved: 0, usd: 0, lastAt: 0, rates: [] };
|
|
684
|
+
var g = groups[key];
|
|
685
|
+
g.repos++;
|
|
686
|
+
g.checkpoints += (r.checkpointCount || 0);
|
|
687
|
+
g.tokensSaved += (r.tokensSaved || 0);
|
|
688
|
+
if (r.inputRate) { g.usd += (r.tokensSaved || 0) * r.inputRate; g.rates.push(r.inputRate); }
|
|
689
|
+
if (r.lastCompactedAt && r.lastCompactedAt > g.lastAt) g.lastAt = r.lastCompactedAt;
|
|
690
|
+
}
|
|
691
|
+
var arr = [];
|
|
692
|
+
for (var k in groups) { if (Object.prototype.hasOwnProperty.call(groups, k)) arr.push(groups[k]); }
|
|
693
|
+
arr.sort(function(a, b) { return b.tokensSaved - a.tokensSaved; });
|
|
694
|
+
rows.innerHTML = arr.map(function(g) {
|
|
695
|
+
var usd = g.usd > 0 ? '$' + g.usd.toFixed(4) : '—';
|
|
696
|
+
var when = g.lastAt ? new Date(g.lastAt).toLocaleString() : '—';
|
|
697
|
+
return '<tr>' +
|
|
698
|
+
'<td><span class="repo-model">' + sanitize(g.model) + '</span></td>' +
|
|
699
|
+
'<td>' + sanitize(g.provider) + '</td>' +
|
|
700
|
+
'<td class="num">' + g.repos.toLocaleString() + '</td>' +
|
|
701
|
+
'<td class="num">' + g.checkpoints.toLocaleString() + '</td>' +
|
|
702
|
+
'<td class="num">' + g.tokensSaved.toLocaleString() + '</td>' +
|
|
703
|
+
'<td class="num">' + sanitize(usd) + '</td>' +
|
|
704
|
+
'<td class="num">' + sanitize(when) + '</td>' +
|
|
705
|
+
'</tr>';
|
|
706
|
+
}).join('');
|
|
628
707
|
}
|
|
629
708
|
|
|
630
709
|
// Per-repo detail modal ---------------------------------------------------
|
|
@@ -757,7 +836,47 @@ export async function launchDashboardServer(stateDir) {
|
|
|
757
836
|
// ── New server ────────────────────────────────────────────────────────────
|
|
758
837
|
mkdirSync(stateDir, { recursive: true });
|
|
759
838
|
let eventOffset = 0;
|
|
839
|
+
// Overlay the live current-repo snapshot (snapshot.json, rewritten every
|
|
840
|
+
// context event) onto its registry row so the All-repos / Summary views stay
|
|
841
|
+
// in sync with the live menu bar + Current-repo card in real time. The
|
|
842
|
+
// registry (index.sqlite) is only written on repo-switch (bindRepo), so
|
|
843
|
+
// without this the current repo's row freezes between switches. Read-only —
|
|
844
|
+
// no extra writes to index.sqlite. Matched by stateDir, which equals the
|
|
845
|
+
// value this server was launched with (runtime.currentStateDir).
|
|
846
|
+
function overlayCurrentRepo(idx) {
|
|
847
|
+
if (!idx || !idx.repos.length)
|
|
848
|
+
return;
|
|
849
|
+
let snap = null;
|
|
850
|
+
try {
|
|
851
|
+
snap = readSnapshot(snapshotPath);
|
|
852
|
+
}
|
|
853
|
+
catch {
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (!snap || !snap.repo)
|
|
857
|
+
return;
|
|
858
|
+
const cur = idx.repos.find((r) => r.stateDir === stateDir);
|
|
859
|
+
if (!cur)
|
|
860
|
+
return;
|
|
861
|
+
const prevSaved = cur.tokensSaved;
|
|
862
|
+
const prevCp = cur.checkpointCount;
|
|
863
|
+
const prevBytes = cur.compressedOriginalBytes;
|
|
864
|
+
const comp = snap.compression?.repo;
|
|
865
|
+
const liveSaved = comp ? comp.tokensFreed : (snap.repo.tokensSaved ?? prevSaved);
|
|
866
|
+
const liveCp = snap.repo.checkpointCount ?? prevCp;
|
|
867
|
+
const liveBytes = snap.integrity?.compressedOriginalBytes ?? prevBytes;
|
|
868
|
+
cur.tokensSaved = liveSaved;
|
|
869
|
+
cur.checkpointCount = liveCp;
|
|
870
|
+
cur.compressedOriginalBytes = liveBytes;
|
|
871
|
+
if (idx.summary) {
|
|
872
|
+
idx.summary.totalTokensSaved += liveSaved - prevSaved;
|
|
873
|
+
idx.summary.totalCheckpoints += liveCp - prevCp;
|
|
874
|
+
idx.summary.totalCompressedOriginalBytes += liveBytes - prevBytes;
|
|
875
|
+
}
|
|
876
|
+
idx.updatedAt = snap.updatedAt ?? idx.updatedAt;
|
|
877
|
+
}
|
|
760
878
|
const server = createServer((req, res) => {
|
|
879
|
+
// guardrails-allow PREVENT-PI-004: optional, user-triggered /dashboard localhost server (loopback-only) — CORS open for local browser access
|
|
761
880
|
// CORS for local access
|
|
762
881
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
763
882
|
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
@@ -790,8 +909,11 @@ export async function launchDashboardServer(stateDir) {
|
|
|
790
909
|
// directly from SQLite (index.sqlite). Lets one dashboard show every repo's
|
|
791
910
|
// checkpoints, tokens saved, and active model. Read-only.
|
|
792
911
|
if (req.url === "/api/index") {
|
|
912
|
+
const idx = readIndex();
|
|
913
|
+
if (idx)
|
|
914
|
+
overlayCurrentRepo(idx);
|
|
793
915
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
794
|
-
res.end(JSON.stringify(
|
|
916
|
+
res.end(JSON.stringify(idx ?? { updatedAt: null, summary: null, repos: [] }));
|
|
795
917
|
return;
|
|
796
918
|
}
|
|
797
919
|
// /api/repos — registry list. Optional `?active=24h` filters to repos
|
|
@@ -800,8 +922,10 @@ export async function launchDashboardServer(stateDir) {
|
|
|
800
922
|
if (req.url?.startsWith("/api/repos")) {
|
|
801
923
|
const url = new URL(req.url, "http://x");
|
|
802
924
|
const activeParam = url.searchParams.get("active");
|
|
803
|
-
const idx = readIndex()
|
|
804
|
-
|
|
925
|
+
const idx = readIndex();
|
|
926
|
+
if (idx)
|
|
927
|
+
overlayCurrentRepo(idx);
|
|
928
|
+
let repos = idx?.repos ?? [];
|
|
805
929
|
if (activeParam) {
|
|
806
930
|
const m = /^(\d+)h$/.exec(activeParam);
|
|
807
931
|
if (m) {
|
|
@@ -810,21 +934,23 @@ export async function launchDashboardServer(stateDir) {
|
|
|
810
934
|
}
|
|
811
935
|
}
|
|
812
936
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
813
|
-
res.end(JSON.stringify({ updatedAt: idx
|
|
937
|
+
res.end(JSON.stringify({ updatedAt: idx?.updatedAt ?? null, repos, count: repos.length }));
|
|
814
938
|
return;
|
|
815
939
|
}
|
|
816
940
|
// /api/summary — header tiles without the full repo list (keeps payload
|
|
817
941
|
// small for embed scenarios). activeRepos mirrors the /api/repos?active=24h
|
|
818
942
|
// count so the dashboard can render the active badge alongside totals.
|
|
819
943
|
if (req.url?.startsWith("/api/summary")) {
|
|
820
|
-
const idx = readIndex()
|
|
821
|
-
|
|
944
|
+
const idx = readIndex();
|
|
945
|
+
if (idx)
|
|
946
|
+
overlayCurrentRepo(idx);
|
|
947
|
+
const repos = idx?.repos ?? [];
|
|
822
948
|
const cutoffSec = Math.floor(Date.now() / 1000) - 24 * 3600;
|
|
823
949
|
const activeRepos = repos.filter((r) => (r.lastSeen ?? 0) >= cutoffSec).length;
|
|
824
950
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
825
951
|
res.end(JSON.stringify({
|
|
826
|
-
updatedAt: idx
|
|
827
|
-
summary: idx
|
|
952
|
+
updatedAt: idx?.updatedAt ?? null,
|
|
953
|
+
summary: idx?.summary ?? null,
|
|
828
954
|
activeRepos,
|
|
829
955
|
totalRepos: repos.length,
|
|
830
956
|
}));
|
|
@@ -123,8 +123,8 @@ describe("multi-repo /api/index (S19)", () => {
|
|
|
123
123
|
process.env.MEGACOMPACT_INDEX_DIR = indexDir;
|
|
124
124
|
process.env.MEGACOMPACT_DASHBOARD_PORT = "19321"; // private base, non-colliding
|
|
125
125
|
const { upsertRepoRegistry } = await import("../src/store/sqlite.js");
|
|
126
|
-
upsertRepoRegistry({ repoRoot: "/home/u/repoA", displayName: "repoA", stateDir: dir, checkpointCount: 3, tokensSaved: 1000, compressedOriginalBytes: 0 }, indexDir);
|
|
127
|
-
upsertRepoRegistry({ repoRoot: "/home/u/repoB", displayName: "repoB", stateDir: dir, checkpointCount: 5, tokensSaved: 2000, compressedOriginalBytes: 0 }, indexDir);
|
|
126
|
+
upsertRepoRegistry({ repoRoot: "/home/u/repoA", displayName: "repoA", stateDir: join(dir, "a"), checkpointCount: 3, tokensSaved: 1000, compressedOriginalBytes: 0 }, indexDir);
|
|
127
|
+
upsertRepoRegistry({ repoRoot: "/home/u/repoB", displayName: "repoB", stateDir: join(dir, "b"), checkpointCount: 5, tokensSaved: 2000, compressedOriginalBytes: 0 }, indexDir);
|
|
128
128
|
const child = spawn(process.execPath, [SERVER_ENTRY, dir], { stdio: "ignore" });
|
|
129
129
|
try {
|
|
130
130
|
await waitFor(async () => {
|