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.
- package/dist/extensions/dashboard-server.js +30 -9
- package/dist/extensions/mega-commands.js +223 -0
- package/dist/extensions/mega-compact.js +19 -1073
- package/dist/extensions/mega-compact.test.js +28 -0
- package/dist/extensions/mega-config.js +100 -0
- package/dist/extensions/mega-dashboard-cmds.js +214 -0
- package/dist/extensions/mega-dashboard.js +35 -0
- package/dist/extensions/mega-events.js +167 -0
- package/dist/extensions/mega-pipeline.js +140 -0
- package/dist/extensions/mega-runtime.js +370 -0
- package/dist/src/store/sqlite.js +60 -0
- package/extensions/dashboard-server.ts +30 -9
- package/extensions/mega-commands.ts +250 -0
- package/extensions/mega-compact.test.ts +30 -0
- package/extensions/mega-compact.ts +20 -1214
- package/extensions/mega-config.ts +120 -0
- package/extensions/mega-dashboard-cmds.ts +209 -0
- package/extensions/mega-dashboard.ts +107 -0
- package/extensions/mega-events.ts +180 -0
- package/extensions/mega-pipeline.ts +179 -0
- package/extensions/mega-runtime.ts +386 -0
- package/package.json +1 -1
- package/src/store/sqlite.ts +96 -0
|
@@ -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.
|
|
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",
|
package/src/store/sqlite.ts
CHANGED
|
@@ -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);
|