pi-mega-compact 0.4.14 → 0.4.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,179 @@
1
+ /**
2
+ * mega-pipeline.ts — the compaction + recall pipelines.
3
+ *
4
+ * `runCompact` runs the full Trident pipeline (fast-gate aside) and persists a
5
+ * checkpoint. `doRecall` is the unified Layer-5 recall entry point. Both mutate
6
+ * the shared MegaRuntime (token accounting, ticker, status, events) and are
7
+ * driven by the event + command handlers in mega-events.ts / mega-commands.ts.
8
+ */
9
+
10
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
11
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
12
+ import { compactSession } from "../src/engine.js";
13
+ import { recallAndInline } from "../src/recall.js";
14
+ import { normalizeSessionId } from "../src/store.js";
15
+ import { touchSession, logDaily } from "../src/store/sqlite.js";
16
+ import {
17
+ MegaRuntime,
18
+ C,
19
+ MARKER_TYPE,
20
+ } from "./mega-runtime.js";
21
+ import { resolveRepoRoot, type MegaConfig } from "./mega-config.js";
22
+
23
+ export type RunCompactResult =
24
+ | { skipped: true }
25
+ | { skipped: false; result: ReturnType<typeof compactSession>; keepFrom: number; saved: number };
26
+
27
+ /** Run the full compaction pipeline and persist a checkpoint. Returns the result. */
28
+ export function runCompact(
29
+ pi: ExtensionAPI,
30
+ runtime: MegaRuntime,
31
+ config: MegaConfig,
32
+ ctx: ExtensionContext,
33
+ messages: AgentMessage[],
34
+ opts: { keepFrom?: number; summary?: string } = {},
35
+ ): RunCompactResult {
36
+ runtime.bindRepo(ctx.cwd);
37
+ const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
38
+ runtime.resetRuntime(sid);
39
+ runtime.rt.sessionId = sid;
40
+
41
+ const view = runtime.engineView(messages);
42
+ const keepFrom = opts.keepFrom ?? Math.max(0, view.length - config.preserveRecent);
43
+ if (keepFrom <= 0) return { skipped: true };
44
+
45
+ runtime.pulsing = true; // animate the status line while the (sync) pipeline runs
46
+ const result = compactSession(
47
+ {
48
+ sessionId: sid,
49
+ messages: view,
50
+ keepFrom,
51
+ summary: opts.summary,
52
+ timestamp: Date.now(),
53
+ onTier: runtime.makeTierCallback(ctx),
54
+ },
55
+ runtime.store,
56
+ );
57
+ runtime.pulsing = false;
58
+
59
+ if (result.skipped) return { skipped: true };
60
+ if (!result.deduped) {
61
+ runtime.rt.persistedThisSession = true;
62
+ runtime.rt.lastCheckpointId = result.checkpointId;
63
+ }
64
+ runtime.rt.lastCompactedFrom = result.compactedFrom;
65
+ runtime.rt.lastCompactedTokens = result.tokenEstimate;
66
+ runtime.rt.dedupAttempts++;
67
+ // Honest "tokens saved" for this session-instance only:
68
+ // new checkpoint → original − stored
69
+ // deduped onto existing → whole original region (nothing new stored)
70
+ // Resets to 0 on session_start (rt is rebuilt) — so a fresh session shows 0
71
+ // while the repo's cumulative saved (SQLite meta) keeps the running total.
72
+ const saved = result.deduped
73
+ ? result.originalTokenEstimate
74
+ : Math.max(0, result.originalTokenEstimate - result.tokenEstimate);
75
+ runtime.rt.tokensSaved += saved;
76
+ if (result.deduped) runtime.rt.dedupSkips++;
77
+ // Grow the rolling "saved" goal so the progress bar always has a fresh
78
+ // denominator (we don't want it pinned at 100% once we pass an old target).
79
+ if (runtime.rt.tokensSaved > runtime.savedGoal) runtime.savedGoal = Math.ceil((runtime.rt.tokensSaved * 1.25) / 10_000) * 10_000;
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).
83
+ const files = result.filesModified ?? [];
84
+ const fileLabel = files.length
85
+ ? files.map((f) => f.split("/").pop() ?? f).slice(0, 2).join(", ")
86
+ : result.regionHash.slice(0, 8);
87
+ runtime.currentActivity = result.deduped
88
+ ? `♻ deduped ${fileLabel}`
89
+ : `🗜 compacted ${result.checkpointId} · ${fileLabel}`;
90
+ runtime.lastActivityAt = Date.now();
91
+ // Explain-why line: surfaced while fresh. Pulls the dedup reason (which for
92
+ // L2 includes the cosine sim) so the user sees WHY a region was kept/dropped.
93
+ runtime.lastWhy = result.deduped
94
+ ? `why: deduped@${result.dedupReason ?? "tier"}`
95
+ : `why: compacted → ${result.checkpointId}`;
96
+ // Recall/activity ticker: record this event in the ring buffer.
97
+ const savedK = (saved / 1000).toFixed(1);
98
+ runtime.pushTicker(
99
+ result.deduped
100
+ ? `${C.green}♻${C.reset} deduped ${fileLabel} · ${savedK}k saved`
101
+ : `${C.cyan}🗜${C.reset} ${result.checkpointId} · +${savedK}k · ${fileLabel}`,
102
+ );
103
+ // The per-tier trace has settled into the final outcome — fold it back into
104
+ // the activity line and stop showing the live trace.
105
+ runtime.tierTrace = undefined;
106
+
107
+ // Record session activity + a daily-log entry in the per-repo SQLite store
108
+ // (foundation for resume-sessions / daily-log features). Best-effort — never
109
+ // block a compaction on bookkeeping.
110
+ try {
111
+ const root = resolveRepoRoot(ctx.cwd);
112
+ touchSession(sid, root, runtime.currentStateDir);
113
+ logDaily(sid, "compact", result.checkpointId, saved, runtime.currentStateDir);
114
+ } catch {
115
+ /* non-fatal: stats bookkeeping only */
116
+ }
117
+
118
+ // Sentinel marker: a non-LLM bookkeeping entry so subsequent triggers can
119
+ // skip re-vectorizing an already-compacted region (zero token cost).
120
+ pi.appendEntry(MARKER_TYPE, {
121
+ checkpointId: result.checkpointId,
122
+ regionHash: result.regionHash,
123
+ tokenEstimate: result.tokenEstimate,
124
+ deduped: result.deduped,
125
+ });
126
+
127
+ runtime.setStatus(
128
+ ctx,
129
+ runtime.rt.persistedThisSession
130
+ ? `mega-compact: ${result.checkpointId} · ${saved} tok saved`
131
+ : `mega-compact: ready`,
132
+ );
133
+ runtime.logger.info("compact", {
134
+ sessionId: sid,
135
+ checkpointId: result.checkpointId ?? "(deduped)",
136
+ deduped: result.deduped,
137
+ tokenEstimate: saved,
138
+ compactedFrom: result.compactedFrom,
139
+ });
140
+ runtime.dashboard.event("compact", {
141
+ sessionId: sid,
142
+ checkpointId: result.checkpointId ?? "(deduped)",
143
+ deduped: result.deduped,
144
+ tokenEstimate: saved,
145
+ compactedFrom: result.compactedFrom,
146
+ });
147
+ runtime.snapshot(ctx);
148
+ return { skipped: false, result, keepFrom, saved };
149
+ }
150
+
151
+ /**
152
+ * Unified recall (Layer 5). The ONE path that injects. Returns the recall
153
+ * result; callers decide whether to stage it for before_agent_start (resume)
154
+ * or report it (command).
155
+ */
156
+ export function doRecall(
157
+ runtime: MegaRuntime,
158
+ config: MegaConfig,
159
+ ctx: ExtensionContext,
160
+ query: string,
161
+ source: "resume" | "command",
162
+ ) {
163
+ runtime.bindRepo(ctx.cwd);
164
+ const sid = normalizeSessionId(ctx.sessionManager.getSessionId());
165
+ const result = recallAndInline(
166
+ { sessionId: sid, query, limit: config.autoInlineK, source, skipInjected: true },
167
+ runtime.store,
168
+ );
169
+ runtime.dashboard.event("recall", { source, query: query.slice(0, 120), injected: result.toInject.length, empty: result.empty });
170
+ if (!result.empty && result.toInject.length > 0) {
171
+ const top = result.toInject[0];
172
+ const scorePct = Math.round((top.score ?? 0) * 100);
173
+ const files = top.checkpoint.filesModified ?? [];
174
+ const label = files.length ? files.map((f) => f.split("/").pop() ?? f).slice(0, 2).join(", ") : top.checkpoint.checkpointId;
175
+ runtime.pushTicker(`${C.amber}↩${C.reset} recalled ${top.checkpoint.checkpointId} · ${scorePct}% · ${label}`);
176
+ runtime.lastWhy = `why: recalled@${scorePct}% (${result.toInject.length} chkpt)`;
177
+ }
178
+ return result;
179
+ }
@@ -0,0 +1,386 @@
1
+ /**
2
+ * mega-runtime.ts — the shared live state of the mega-compact extension.
3
+ *
4
+ * The original mega-compact.ts was a single large closure over ~20 mutable
5
+ * variables. This module lifts that state into a `MegaRuntime` class so the
6
+ * event/command/pipeline modules can share it without re-declaring it. All
7
+ * behavior (store/dashboard rebinding, dashboard snapshot shape, the
8
+ * above-editor widget math, model capture) is preserved byte-for-byte from the
9
+ * original closure.
10
+ */
11
+
12
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
13
+ import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
14
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
15
+ import { join } from "node:path";
16
+ import { VectorStore } from "../src/vectorStore.js";
17
+ import { toEngineMessages } from "../src/adapt.js";
18
+ import { normalizeSessionId } from "../src/store.js";
19
+ import { Logger } from "../src/log.js";
20
+ import { recordModelSnapshot, type ModelSnapshot } from "../src/store/sqlite.js";
21
+ import { repoStateDir, resolveRepoRoot, type MegaConfig } from "./mega-config.js";
22
+ import { Dashboard, type DashboardSnapshot } from "./mega-dashboard.js";
23
+
24
+ export const STATUS_KEY = "mega-compact";
25
+ export const WIDGET_KEY = "mega-compact-stats";
26
+ export const MARKER_TYPE = "mega-compact-marker";
27
+
28
+ /** Per-session runtime state kept in the closure (mirrors neuralwatt-mcr). */
29
+ interface SessionRuntime {
30
+ sessionId: string;
31
+ persistedThisSession: boolean;
32
+ lastCheckpointId: string | undefined;
33
+ lastCompactedFrom: number;
34
+ lastCompactedTokens: number;
35
+ dedupSkips: number; // compactions skipped because regionHash already stored
36
+ dedupAttempts: number; // total compaction attempts (for hit-rate denominator)
37
+ tokensSaved: number; // this session-instance only: reset on session_start
38
+ }
39
+
40
+ /** ANSI palette for the toolbar. The pi TUI's Text component preserves ANSI
41
+ * escape codes (see wrapTextWithAnsi), so raw escapes render as colors. No
42
+ * chalk dependency needed — these are just strings. */
43
+ export const C = {
44
+ reset: "\x1b[0m",
45
+ dim: "\x1b[2m",
46
+ bold: "\x1b[1m",
47
+ amber: "\x1b[38;5;214m", // tier / ready
48
+ green: "\x1b[38;5;120m", // saved
49
+ cyan: "\x1b[38;5;51m", // used / live activity
50
+ teal: "\x1b[38;5;37m", // processing (compress/dedup)
51
+ magenta: "\x1b[38;5;201m", // dedup rate
52
+ blue: "\x1b[38;5;75m", // repo totals
53
+ gray: "\x1b[38;5;245m", // labels
54
+ };
55
+
56
+ const PULSE = ["◐", "◓", "◑", "◒"];
57
+
58
+ interface TickerEntry { text: string; at: number; }
59
+
60
+ export class MegaRuntime {
61
+ config: MegaConfig;
62
+ // Store/dashboard/logger are rebound per-repo by bindRepo() so each git repo
63
+ // gets its own isolated state dir. They start bound to the global default.
64
+ store: VectorStore;
65
+ logger: Logger;
66
+ dashboard: Dashboard;
67
+ activeRepoRoot: string | null = null;
68
+ currentStateDir: string;
69
+
70
+ // The only mutable per-session state. Reset on session_start / session_tree.
71
+ rt: SessionRuntime = {
72
+ sessionId: normalizeSessionId(undefined),
73
+ persistedThisSession: false,
74
+ lastCheckpointId: undefined,
75
+ lastCompactedFrom: 0,
76
+ lastCompactedTokens: 0,
77
+ dedupSkips: 0,
78
+ dedupAttempts: 0,
79
+ tokensSaved: 0,
80
+ };
81
+ debounceUntil = 0;
82
+ // Agent tracking for real-time widget updates
83
+ activeAgents = 0;
84
+ currentTurn = 0;
85
+ // Recall block produced by auto-inline (resume/branch) that the next
86
+ // before_agent_start should prepend to the system prompt. Unset after use.
87
+ pendingRecallBlock: string | undefined;
88
+ statusKey: string | undefined; // current status text for dashboard
89
+ // Active model/provider (for real cost estimation). Captured from ctx.model
90
+ // on model_select + session_start; persisted to SQL so cost + the dashboard
91
+ // can read it without a live ctx.
92
+ currentModel: ModelSnapshot | undefined;
93
+ // Live "what it's doing right now" line for the toolbar. Set on each
94
+ // compaction; shown in teal while recent, then kept as the last-seen action so
95
+ // the widget is never blank. Cleared on session reset.
96
+ currentActivity: string | undefined;
97
+ lastActivityAt = 0;
98
+ // Live per-tier dedup trace (Phase 1): e.g. "L0 ✓ → L1 ✓ → L2 0.91 → stored".
99
+ // Built from the store's sync onTier callback during a compaction so the user
100
+ // watches each tier evaluate in real time. Cleared once the outcome settles.
101
+ tierTrace: string | undefined;
102
+ // Phase 3 — standout toolbar state.
103
+ // Recall/activity ticker: a small ring buffer (≤5) of recent compact/recall
104
+ // events so the widget shows a live history instead of a single last action.
105
+ ticker: TickerEntry[] = [];
106
+ readonly TICKER_MAX = 5;
107
+ // Pulsing status: set true while a compaction is in flight, cleared on result.
108
+ pulsing = false;
109
+ // Rolling "saved" goal for the progress bar — grows as we save more, so the
110
+ // bar always has a meaningful denominator (never sits at 100% forever).
111
+ savedGoal = 50_000;
112
+ // Last explain-why line (dedup reason / anchor-kept / superseded), surfaced
113
+ // while fresh.
114
+ lastWhy: string | undefined = undefined;
115
+
116
+ // Context tracking for the dashboard (updated in the context handler).
117
+ lastCtxTokens: number | null = null;
118
+ lastCtxPercent: number | null = null;
119
+ lastCtxWindow = 0;
120
+
121
+ constructor(config: MegaConfig) {
122
+ this.config = config;
123
+ this.store = new VectorStore({ dedupSim: config.dedupSim, stateDir: config.stateDir });
124
+ this.logger = new Logger({ enabled: config.debug, path: join(config.stateDir, "mega-compact.log") });
125
+ this.dashboard = new Dashboard(config.stateDir);
126
+ this.currentStateDir = config.stateDir;
127
+ }
128
+
129
+ // ---- per-repo binding -----------------------------------------------------
130
+
131
+ /**
132
+ * Point store/dashboard/logger at the current repo's state dir. Rebuilds the
133
+ * instances only when the repo root changes, so cross-repo dedup stats, db,
134
+ * and events are fully isolated. Falls back to the global default outside git.
135
+ */
136
+ bindRepo(cwd: string | undefined): string {
137
+ const dir = cwd ? repoStateDir(cwd, this.config.stateDir) : this.config.stateDir;
138
+ const key = cwd ? resolveRepoRoot(cwd) ?? dir : dir;
139
+ if (key === this.activeRepoRoot) return dir;
140
+ this.activeRepoRoot = key;
141
+ this.currentStateDir = dir;
142
+ this.store = new VectorStore({ dedupSim: this.config.dedupSim, stateDir: dir });
143
+ this.logger = new Logger({ enabled: this.config.debug, path: join(dir, "mega-compact.log") });
144
+ this.dashboard = new Dashboard(dir);
145
+ return dir;
146
+ }
147
+
148
+ // ---- dashboard snapshot + widget ------------------------------------------
149
+
150
+ /** Collect live state and write it to disk (+ paint the above-editor widget). */
151
+ snapshot(ctx?: ExtensionContext): void {
152
+ if (ctx) this.bindRepo(ctx.cwd);
153
+ const st = this.store.stats(this.rt.sessionId);
154
+ const repo = this.store.repoStats();
155
+ const di = this.store.dataInvariant();
156
+ const armed = this.lastCtxPercent != null && this.lastCtxPercent >= this.config.fastGatePct;
157
+ const ready = armed && (this.lastCtxTokens ?? 0) >= this.config.thresholdTokens;
158
+ this.dashboard.snapshot({
159
+ version: 1,
160
+ updatedAt: new Date().toISOString(),
161
+ tier: this.config.tier,
162
+ config: {
163
+ fastGatePct: this.config.fastGatePct,
164
+ thresholdTokens: this.config.thresholdTokens,
165
+ anchorUserMessages: this.config.anchorUserMessages,
166
+ preserveRecent: this.config.preserveRecent,
167
+ auto: this.config.auto,
168
+ autoInline: this.config.autoInline,
169
+ },
170
+ session: {
171
+ id: this.rt.sessionId,
172
+ state: this.statusKey ?? "idle",
173
+ persistedThisSession: this.rt.persistedThisSession,
174
+ lastCheckpointId: this.rt.lastCheckpointId ?? null,
175
+ lastCompactedFrom: this.rt.lastCompactedFrom,
176
+ lastCompactedTokens: this.rt.lastCompactedTokens,
177
+ dedupSkips: this.rt.dedupSkips,
178
+ dedupAttempts: this.rt.dedupAttempts,
179
+ },
180
+ context: { tokens: this.lastCtxTokens, percent: this.lastCtxPercent, contextWindow: this.lastCtxWindow },
181
+ trigger: { armed, ready, currentTokens: this.lastCtxTokens, thresholdTokens: this.config.thresholdTokens, fastGatePct: this.config.fastGatePct },
182
+ crew: { activeAgents: this.activeAgents, currentTurn: this.currentTurn },
183
+ store: { checkpointCount: st.checkpointCount, totalTokenEstimate: st.totalTokenEstimate, originalTokens: st.originalTokens, tokensSaved: this.rt.tokensSaved, injectedCount: st.injectedCount, dedupHitRate: st.dedupHitRate, storageDedupRate: st.storageDedupRate, dedupAttempts: st.dedupAttempts, dedupCollapsed: st.dedupCollapsed },
184
+ repo: {
185
+ checkpointCount: repo.checkpointCount,
186
+ totalTokenEstimate: repo.totalTokenEstimate,
187
+ originalTokens: repo.originalTokens,
188
+ tokensSaved: repo.tokensSaved,
189
+ sessionCount: repo.sessionCount,
190
+ dedupAttempts: repo.dedupAttempts,
191
+ dedupCollapsed: repo.dedupCollapsed,
192
+ storageDedupRate: repo.storageDedupRate,
193
+ },
194
+ integrity: {
195
+ regionsRetained: di.regionsRetained,
196
+ compressedOriginalBytes: di.compressedOriginalBytes,
197
+ duplicatesCollapsed: di.duplicatesCollapsed,
198
+ bytesPermanentlyDeleted: di.bytesPermanentlyDeleted,
199
+ },
200
+ } as DashboardSnapshot);
201
+
202
+ // Live stats widget above the editor
203
+ if (ctx) {
204
+ const tokStr = this.lastCtxTokens != null ? `${Math.round(this.lastCtxTokens / 1000)}k` : "?";
205
+ const maxStr = this.lastCtxWindow > 0 ? `${Math.round(this.lastCtxWindow / 1000)}k` : "?";
206
+ const pctStr = this.lastCtxPercent != null ? `${Math.round(this.lastCtxPercent * 10) / 10}%` : "?%";
207
+ const triggerLabel = ready ? `${C.green}● ready${C.reset}` : armed ? `${C.amber}◐ armed${C.reset}` : `${C.gray}○ idle${C.reset}`;
208
+ // Storage dedup rate is cumulative (store-wide, per-repo) and survives
209
+ // session resets. Always show a number: 0% before any compaction, a
210
+ // decimal for sub-10% rates so small-but-real dedup isn't rounded away.
211
+ const storageRate = st.storageDedupRate; // 0..1
212
+ const dedupStr = storageRate * 100 >= 10
213
+ ? `${Math.round(storageRate * 100)}%`
214
+ : `${(storageRate * 100).toFixed(1)}%`;
215
+ // saved = tokens removed from context (cumulative original − stored).
216
+ // Show BOTH this-session (rt.tokensSaved) and repo-wide-total
217
+ // (repo.tokensSaved) so the user sees per-session progress vs the running
218
+ // repo total. "used" = stored checkpoint tokens (repo.totalTokenEstimate
219
+ // vs st.totalTokenEstimate). Use "k" only at/above 1000 so small-but-real
220
+ // numbers stay visible (previously Math.round(x/1000) zeroed <1000).
221
+ const fmt = (x: number) => (x >= 1000 ? `${(x / 1000).toFixed(1)}k` : `${x}`);
222
+ const savedStr = `${C.green}${fmt(this.rt.tokensSaved)} sess${C.reset} / ${C.blue}${fmt(repo.tokensSaved)} repo${C.reset}`;
223
+ const usedStr = `${C.cyan}${fmt(st.totalTokenEstimate)} sess${C.reset} / ${C.blue}${fmt(repo.totalTokenEstimate)} repo${C.reset}`;
224
+ const agentStr = this.activeAgents > 0 ? ` │ 🤖 ${this.activeAgents} agent${this.activeAgents === 1 ? "" : "s"}` : "";
225
+ const turnStr = this.currentTurn > 0 ? ` │ turn ${this.currentTurn}` : "";
226
+ // Phase 3 — pulsing status glyph while a compaction is in flight.
227
+ const pulse = this.pulsing ? `${C.cyan}${PULSE[Math.floor(Date.now() / 250) % PULSE.length]}${C.reset} ` : "";
228
+ const lines = [
229
+ ` ${C.amber}⚡ ${this.config.tier}${C.reset} │ ${tokStr}/${maxStr} tokens (${C.bold}${pctStr}${C.reset}) │ ${st.checkpointCount} saved${agentStr}${turnStr}`,
230
+ ` ${triggerLabel} │ ${C.magenta}repeat-skipped: ${dedupStr}${C.reset} │ ${C.gray}memory held:${C.reset} ${usedStr} │ ${C.gray}space freed:${C.reset} ${savedStr}`,
231
+ ];
232
+ // Phase 3 — compact progress bar: session tokens saved toward the rolling goal.
233
+ if (this.rt.tokensSaved > 0) {
234
+ const goal = Math.max(this.savedGoal, 1);
235
+ const pct = Math.min(100, Math.round((this.rt.tokensSaved / goal) * 100));
236
+ const filled = Math.round((pct / 100) * 10);
237
+ const bar = "▓".repeat(filled) + "░".repeat(10 - filled);
238
+ lines.push(` ${C.green}saved ${fmt(this.rt.tokensSaved)} ${bar}${C.reset} ${pct}% of ${fmt(goal)}`);
239
+ }
240
+ // Live "now processing" line — teal while fresh (≤4s), then the last-seen
241
+ // action keeps the widget lively. Cleared on session reset.
242
+ const fresh = Date.now() - this.lastActivityAt < 4000;
243
+ if (this.tierTrace && fresh) {
244
+ lines.push(` ${pulse}${this.tierTrace}`);
245
+ } else if (this.currentActivity) {
246
+ lines.push(` ${fresh ? C.teal : C.dim}${this.currentActivity}${C.reset}`);
247
+ } else if (this.pulsing) {
248
+ lines.push(` ${pulse}${C.teal}compacting…${C.reset}`);
249
+ }
250
+ // Phase 3 — explain-why line (fresh only).
251
+ if (this.lastWhy && fresh) lines.push(` ${C.gray}${this.lastWhy}${C.reset}`);
252
+ // Phase 3 — recall/activity ticker (most-recent first), fresh only.
253
+ if (fresh) {
254
+ for (let i = this.ticker.length - 1; i >= 0; i--) {
255
+ if (lines.length >= 9) break; // leave room for the hint line (MAX 10)
256
+ lines.push(` ${i === this.ticker.length - 1 ? "" : C.dim}${this.ticker[i].text}${C.reset}`);
257
+ }
258
+ }
259
+ // Plain-language hint so first-time users understand the widget. Always
260
+ // last, dimmed. "/mega-help explains these terms."
261
+ if (lines.length < 10) {
262
+ lines.push(` ${C.dim}auto-compresses old context to free space · nothing deleted · /mega-help${C.reset}`);
263
+ }
264
+ ctx.ui.setWidget(WIDGET_KEY, lines, { placement: "aboveEditor" });
265
+ }
266
+ }
267
+
268
+ setStatus(ctx: ExtensionContext, text: string | undefined): void {
269
+ this.statusKey = text;
270
+ ctx.ui.setStatus(STATUS_KEY, text);
271
+ }
272
+
273
+ resetRuntime(sessionId: string | undefined): void {
274
+ const sid = normalizeSessionId(sessionId);
275
+ if (this.rt.sessionId === sid && this.rt.persistedThisSession) return; // same session, keep checkpoint memory
276
+ this.rt = {
277
+ sessionId: sid,
278
+ persistedThisSession: false,
279
+ lastCheckpointId: undefined,
280
+ lastCompactedFrom: 0,
281
+ lastCompactedTokens: 0,
282
+ dedupSkips: 0,
283
+ dedupAttempts: 0,
284
+ tokensSaved: 0,
285
+ };
286
+ this.statusKey = undefined;
287
+ this.activeAgents = 0;
288
+ this.currentTurn = 0;
289
+ this.currentActivity = undefined;
290
+ this.lastActivityAt = 0;
291
+ this.tierTrace = undefined;
292
+ this.ticker.length = 0;
293
+ this.pulsing = false;
294
+ this.savedGoal = 50_000;
295
+ this.lastWhy = undefined;
296
+ }
297
+
298
+ /**
299
+ * Capture the active model/provider from ctx.model and persist it so cost
300
+ * estimation + the dashboard can read real pricing. Cheap + idempotent-ish:
301
+ * only writes a new row when the model id changes (models change rarely).
302
+ */
303
+ captureModel(ctx: ExtensionContext): void {
304
+ const m = ctx.model;
305
+ if (!m) return;
306
+ if (this.currentModel && this.currentModel.modelId === m.id && this.currentModel.provider === m.provider) return;
307
+ let providerName: string | null = null;
308
+ try { providerName = ctx.modelRegistry?.getProviderDisplayName(m.provider) ?? null; } catch { /* optional */ }
309
+ const snap: Omit<ModelSnapshot, "capturedAt"> = {
310
+ provider: m.provider,
311
+ providerName,
312
+ modelId: m.id,
313
+ modelName: m.name ?? null,
314
+ inputRate: m.cost?.input ?? 0,
315
+ outputRate: m.cost?.output ?? 0,
316
+ contextWindow: m.contextWindow ?? 0,
317
+ maxTokens: m.maxTokens ?? 0,
318
+ reasoning: !!m.reasoning,
319
+ };
320
+ this.currentModel = { ...snap, capturedAt: Date.now() };
321
+ try {
322
+ const repo = resolveRepoRoot(ctx.cwd) ?? this.currentStateDir;
323
+ recordModelSnapshot(repo, snap, this.currentStateDir);
324
+ } catch { /* non-fatal: cost estimation degrades to model-in-memory only */ }
325
+ }
326
+
327
+ /** Build the sync onTier callback that paints the live per-tier trace. */
328
+ makeTierCallback(ctx: ExtensionContext): (ev: { tier: "L0" | "L1" | "L2" | "new"; status: "scanning" | "deduped" | "passed" | "stored"; detail?: string }) => void {
329
+ const order: Array<"L0" | "L1" | "L2" | "new"> = ["L0", "L1", "L2", "new"];
330
+ const seen = new Map<string, string>();
331
+ const glyph = (status: string) =>
332
+ status === "deduped" ? `${C.green}✓${C.reset}` :
333
+ status === "passed" ? `${C.dim}○${C.reset}` :
334
+ status === "scanning" ? `${C.amber}…${C.reset}` :
335
+ `${C.cyan}●${C.reset}`;
336
+ return (ev) => {
337
+ const label =
338
+ ev.tier === "new"
339
+ ? `${C.cyan}stored${C.reset}`
340
+ : `${ev.tier} ${glyph(ev.status)}` +
341
+ (ev.detail ? ` ${C.gray}(${ev.detail})${C.reset}` : "");
342
+ // Show the most recent outcome per tier (collapses re-fires).
343
+ seen.set(ev.tier, label);
344
+ const show: string[] = [];
345
+ for (const t of order) if (seen.has(t)) show.push(seen.get(t)!);
346
+ this.tierTrace = `${C.teal}⚙${C.reset} ${show.join(` ${C.gray}→${C.reset} `)}`;
347
+ this.lastActivityAt = Date.now();
348
+ try { this.snapshot(ctx); } catch { /* non-fatal */ }
349
+ };
350
+ }
351
+
352
+ // Phase 3 — recall/activity ticker ring buffer.
353
+ pushTicker(text: string): void {
354
+ this.ticker.push({ text, at: Date.now() });
355
+ while (this.ticker.length > this.TICKER_MAX) this.ticker.shift();
356
+ this.lastActivityAt = Date.now();
357
+ }
358
+
359
+ /** Convert the messages pi hands us in the `context` event into the engine view. */
360
+ engineView(messages: AgentMessage[]): ReturnType<typeof toEngineMessages> {
361
+ return toEngineMessages(messages);
362
+ }
363
+ }
364
+
365
+ /**
366
+ * Latest user message text — used as the auto-inline recall query.
367
+ * Kept as a free function (not instance state) since it only reads ctx.
368
+ */
369
+ export function recentUserQuery(ctx: ExtensionContext): string {
370
+ try {
371
+ const entries = ctx.sessionManager.getEntries();
372
+ for (let i = entries.length - 1; i >= 0; i--) {
373
+ const msgs = sessionEntryToContextMessages(entries[i]);
374
+ for (let j = msgs.length - 1; j >= 0; j--) {
375
+ if (msgs[j].role === "user") {
376
+ const c = (msgs[j] as { content: unknown }).content;
377
+ if (typeof c === "string") return c;
378
+ if (Array.isArray(c)) return c.map((b: any) => b.text).join(" ");
379
+ }
380
+ }
381
+ }
382
+ } catch {
383
+ /* best-effort */
384
+ }
385
+ return "";
386
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mega-compact",
3
- "version": "0.4.14",
3
+ "version": "0.4.16",
4
4
  "description": "Layered, local, vector-backed context compressor for pi — supersede/collapse/cluster compaction with deduped inline recall.",
5
5
  "type": "module",
6
6
  "license": "BSD-2-Clause",
@@ -174,6 +174,24 @@ function initSchema(db: Database.Database): void {
174
174
  );
175
175
  CREATE INDEX IF NOT EXISTS idx_daily_log_day ON daily_log(day);
176
176
 
177
+ -- Active model/provider for cost estimation + the future multi-repo
178
+ -- dashboard (Phase 5b). One row per (repo, model change); latest wins.
179
+ CREATE TABLE IF NOT EXISTS model_snapshots (
180
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
181
+ repo_root TEXT NOT NULL,
182
+ provider TEXT NOT NULL,
183
+ provider_name TEXT,
184
+ model_id TEXT NOT NULL,
185
+ model_name TEXT,
186
+ input_rate REAL, -- USD per input token (Model.cost.input)
187
+ output_rate REAL, -- USD per output token (Model.cost.output)
188
+ context_window INTEGER,
189
+ max_tokens INTEGER,
190
+ reasoning INTEGER DEFAULT 0,
191
+ captured_at INTEGER
192
+ );
193
+ CREATE INDEX IF NOT EXISTS idx_model_repo ON model_snapshots(repo_root);
194
+
177
195
  -- Lessons learned (future recall/browse feature seed).
178
196
  CREATE TABLE IF NOT EXISTS lessons (
179
197
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -702,6 +720,84 @@ export function repoStats(stateDir: string = getStateDir()): RepoStats {
702
720
  };
703
721
  }
704
722
 
723
+ /** A captured model/provider snapshot (for cost estimation + dashboard). */
724
+ export interface ModelSnapshot {
725
+ provider: string;
726
+ providerName: string | null;
727
+ modelId: string;
728
+ modelName: string | null;
729
+ inputRate: number; // USD per input token
730
+ outputRate: number; // USD per output token
731
+ contextWindow: number;
732
+ maxTokens: number;
733
+ reasoning: boolean;
734
+ capturedAt: number;
735
+ }
736
+
737
+ /** Persist the active model/provider for a repo (latest row wins per repo). */
738
+ export function recordModelSnapshot(
739
+ repoRoot: string,
740
+ snap: Omit<ModelSnapshot, "capturedAt">,
741
+ stateDir: string = getStateDir(),
742
+ ): void {
743
+ const db = openStore(stateDir);
744
+ db.prepare(
745
+ `INSERT INTO model_snapshots
746
+ (repo_root, provider, provider_name, model_id, model_name, input_rate,
747
+ output_rate, context_window, max_tokens, reasoning, captured_at)
748
+ VALUES (@repo_root, @provider, @provider_name, @model_id, @model_name,
749
+ @input_rate, @output_rate, @context_window, @max_tokens, @reasoning, @captured_at)`,
750
+ ).run({
751
+ repo_root: repoRoot,
752
+ provider: snap.provider,
753
+ provider_name: snap.providerName,
754
+ model_id: snap.modelId,
755
+ model_name: snap.modelName,
756
+ input_rate: snap.inputRate,
757
+ output_rate: snap.outputRate,
758
+ context_window: snap.contextWindow,
759
+ max_tokens: snap.maxTokens,
760
+ reasoning: snap.reasoning ? 1 : 0,
761
+ captured_at: Date.now(),
762
+ });
763
+ }
764
+
765
+ /** Most recent model/provider snapshot for a repo, or undefined. */
766
+ export function latestModelSnapshot(stateDir: string = getStateDir()): ModelSnapshot | undefined {
767
+ const db = openStore(stateDir);
768
+ const row = db
769
+ .prepare(
770
+ `SELECT * FROM model_snapshots ORDER BY captured_at DESC LIMIT 1`,
771
+ )
772
+ .get() as
773
+ | {
774
+ provider: string;
775
+ provider_name: string | null;
776
+ model_id: string;
777
+ model_name: string | null;
778
+ input_rate: number;
779
+ output_rate: number;
780
+ context_window: number;
781
+ max_tokens: number;
782
+ reasoning: number;
783
+ captured_at: number;
784
+ }
785
+ | undefined;
786
+ if (!row) return undefined;
787
+ return {
788
+ provider: row.provider,
789
+ providerName: row.provider_name,
790
+ modelId: row.model_id,
791
+ modelName: row.model_name,
792
+ inputRate: row.input_rate,
793
+ outputRate: row.output_rate,
794
+ contextWindow: row.context_window,
795
+ maxTokens: row.max_tokens,
796
+ reasoning: row.reasoning === 1,
797
+ capturedAt: row.captured_at,
798
+ };
799
+ }
800
+
705
801
  /** Close and evict a cached connection (test teardown only). */
706
802
  export function closeStore(stateDir: string): void {
707
803
  const db = cache.get(stateDir);