pi-mega-compact 0.7.8 → 0.8.0
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/README.md +11 -12
- package/dist/extensions/dashboard-server/html.js +1023 -0
- package/dist/extensions/dashboard-server/html.test.js +41 -0
- package/dist/extensions/dashboard-server/index-reader.js +133 -0
- package/dist/extensions/dashboard-server/server.js +530 -0
- package/dist/extensions/dashboard-server/server.test.js +120 -0
- package/dist/extensions/dashboard-server/snapshot.js +43 -0
- package/dist/extensions/dashboard-server/state.js +30 -0
- package/dist/extensions/dashboard-server/types.js +5 -0
- package/dist/extensions/dashboard-server-s32.test.js +181 -0
- package/dist/extensions/dashboard-server.js +7 -1315
- package/dist/extensions/mega-commands.js +162 -134
- package/dist/extensions/mega-compact.js +3 -0
- package/dist/extensions/mega-compact.test.js +90 -21
- package/dist/extensions/mega-conflict-cmds.js +5 -1
- package/dist/extensions/mega-dashboard-cmds.js +29 -22
- package/dist/extensions/mega-db-cmds.js +11 -2
- package/dist/extensions/mega-events/agent-handlers.js +222 -0
- package/dist/extensions/mega-events/compact-handlers.js +162 -0
- package/dist/extensions/mega-events/context-handler.js +249 -0
- package/dist/extensions/mega-events/register.js +21 -0
- package/dist/extensions/mega-events/session-handlers.js +142 -0
- package/dist/extensions/mega-events.js +15 -699
- package/dist/extensions/mega-game-cmds.js +106 -0
- package/dist/extensions/mega-game-cmds.test.js +113 -0
- package/dist/extensions/mega-pipeline/compact.js +324 -0
- package/dist/extensions/mega-pipeline/memory-review.js +38 -0
- package/dist/extensions/mega-pipeline/recall.js +147 -0
- package/dist/extensions/mega-pipeline.js +9 -480
- package/dist/extensions/mega-runtime/helpers.js +40 -0
- package/dist/extensions/mega-runtime/query.js +29 -0
- package/dist/extensions/mega-runtime/state.js +877 -0
- package/dist/extensions/mega-runtime/state.test.js +171 -0
- package/dist/extensions/mega-runtime/widget.js +270 -0
- package/dist/extensions/mega-runtime/widget.test.js +160 -0
- package/dist/extensions/mega-runtime.js +15 -947
- package/dist/src/config/themes.js +84 -0
- package/dist/src/config/themes.test.js +94 -0
- package/dist/src/game/scoring.js +105 -0
- package/dist/src/game/scoring.test.js +98 -0
- package/dist/src/store/sqlite/checkpoints.js +145 -0
- package/dist/src/store/sqlite/dedup-mirror.js +64 -0
- package/dist/src/store/sqlite/foundation.js +38 -0
- package/dist/src/store/sqlite/game-achievements.js +111 -0
- package/dist/src/store/sqlite/game-achievements.test.js +67 -0
- package/dist/src/store/sqlite/game-scores.js +105 -0
- package/dist/src/store/sqlite/game-scores.test.js +106 -0
- package/dist/src/store/sqlite/game-state.js +54 -0
- package/dist/src/store/sqlite/game-state.test.js +76 -0
- package/dist/src/store/sqlite/global-index.js +224 -0
- package/dist/src/store/sqlite/maintenance.js +235 -0
- package/dist/src/store/sqlite/memories.js +164 -0
- package/dist/src/store/sqlite/meta.js +82 -0
- package/dist/src/store/sqlite/model-snapshots.js +47 -0
- package/dist/src/store/sqlite/raptor.js +57 -0
- package/dist/src/store/sqlite/raw-transcript.js +134 -0
- package/dist/src/store/sqlite/schema.js +294 -0
- package/dist/src/store/sqlite/session-state.js +28 -0
- package/dist/src/store/sqlite/stats.js +66 -0
- package/dist/src/store/sqlite/utils.js +120 -0
- package/dist/src/store/sqlite.js +23 -1607
- package/extensions/dashboard-server/html.test.ts +50 -0
- package/extensions/dashboard-server/html.ts +1026 -0
- package/extensions/dashboard-server/index-reader.ts +130 -0
- package/extensions/dashboard-server/server.test.ts +131 -0
- package/extensions/dashboard-server/server.ts +505 -0
- package/extensions/dashboard-server/snapshot.ts +44 -0
- package/extensions/dashboard-server/state.ts +33 -0
- package/extensions/dashboard-server/types.ts +134 -0
- package/extensions/dashboard-server-s32.test.ts +195 -0
- package/extensions/dashboard-server.ts +7 -1431
- package/extensions/mega-commands.ts +33 -10
- package/extensions/mega-compact.test.ts +198 -43
- package/extensions/mega-compact.ts +3 -0
- package/extensions/mega-conflict-cmds.ts +6 -2
- package/extensions/mega-dashboard-cmds.ts +30 -23
- package/extensions/mega-db-cmds.ts +11 -3
- package/extensions/mega-events/agent-handlers.ts +262 -0
- package/extensions/mega-events/compact-handlers.ts +192 -0
- package/extensions/mega-events/context-handler.ts +290 -0
- package/extensions/mega-events/register.ts +37 -0
- package/extensions/mega-events/session-handlers.ts +165 -0
- package/extensions/mega-events.ts +15 -780
- package/extensions/mega-game-cmds.test.ts +137 -0
- package/extensions/mega-game-cmds.ts +122 -0
- package/extensions/mega-pipeline/compact.ts +366 -0
- package/extensions/mega-pipeline/memory-review.ts +46 -0
- package/extensions/mega-pipeline/recall.ts +165 -0
- package/extensions/mega-pipeline.ts +9 -537
- package/extensions/mega-runtime/helpers.ts +68 -0
- package/extensions/mega-runtime/query.ts +29 -0
- package/extensions/mega-runtime/state.test.ts +171 -0
- package/extensions/mega-runtime/state.ts +967 -0
- package/extensions/mega-runtime/widget.test.ts +185 -0
- package/extensions/mega-runtime/widget.ts +359 -0
- package/extensions/mega-runtime.ts +15 -1093
- package/package.json +4 -3
- package/src/config/themes.test.ts +116 -0
- package/src/config/themes.ts +124 -0
- package/src/game/scoring.test.ts +103 -0
- package/src/game/scoring.ts +158 -0
- package/src/store/sqlite/checkpoints.ts +204 -0
- package/src/store/sqlite/dedup-mirror.ts +114 -0
- package/src/store/sqlite/foundation.ts +63 -0
- package/src/store/sqlite/game-achievements.test.ts +80 -0
- package/src/store/sqlite/game-achievements.ts +147 -0
- package/src/store/sqlite/game-scores.test.ts +132 -0
- package/src/store/sqlite/game-scores.ts +168 -0
- package/src/store/sqlite/game-state.test.ts +89 -0
- package/src/store/sqlite/game-state.ts +87 -0
- package/src/store/sqlite/global-index.ts +305 -0
- package/src/store/sqlite/maintenance.ts +294 -0
- package/src/store/sqlite/memories.ts +217 -0
- package/src/store/sqlite/meta.ts +108 -0
- package/src/store/sqlite/model-snapshots.ts +83 -0
- package/src/store/sqlite/raptor.ts +107 -0
- package/src/store/sqlite/raw-transcript.ts +221 -0
- package/src/store/sqlite/schema.ts +305 -0
- package/src/store/sqlite/session-state.ts +38 -0
- package/src/store/sqlite/stats.ts +127 -0
- package/src/store/sqlite/utils.ts +125 -0
- package/src/store/sqlite.ts +23 -2204
|
@@ -0,0 +1,967 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* state.ts — the `MegaRuntime` class: shared live state of the mega-compact
|
|
3
|
+
* extension.
|
|
4
|
+
*
|
|
5
|
+
* The original mega-compact.ts was a single large closure over ~20 mutable
|
|
6
|
+
* variables. This module lifts that state into a `MegaRuntime` class so the
|
|
7
|
+
* event/command/pipeline modules can share it without re-declaring it. All
|
|
8
|
+
* behavior (store/dashboard rebinding, dashboard snapshot shape, the
|
|
9
|
+
* above-editor widget math, model capture) is preserved byte-for-byte from the
|
|
10
|
+
* original closure.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { VectorStore } from "../../src/vectorStore.js";
|
|
18
|
+
import { toEngineMessages } from "../../src/adapt.js";
|
|
19
|
+
import { normalizeSessionId } from "../../src/store.js";
|
|
20
|
+
import { Logger } from "../../src/log.js";
|
|
21
|
+
import {
|
|
22
|
+
recordModelSnapshot,
|
|
23
|
+
latestModelSnapshot,
|
|
24
|
+
upsertRepoRegistry,
|
|
25
|
+
recordRepoModel,
|
|
26
|
+
getDedupStats,
|
|
27
|
+
getCompactCount,
|
|
28
|
+
getRecallInjected,
|
|
29
|
+
getCacheHitTokensSaved,
|
|
30
|
+
getGameState,
|
|
31
|
+
type ModelSnapshot,
|
|
32
|
+
type GameState,
|
|
33
|
+
} from "../../src/store/sqlite.js";
|
|
34
|
+
import { detectCrossRepoDrift } from "../../src/driftDetection.js";
|
|
35
|
+
import {
|
|
36
|
+
repoStateDir,
|
|
37
|
+
resolveRepoRoot,
|
|
38
|
+
pressureRatio,
|
|
39
|
+
pressureFromPct,
|
|
40
|
+
pressureBand,
|
|
41
|
+
effectiveThresholdTokens,
|
|
42
|
+
type MegaConfig,
|
|
43
|
+
type PressureBand,
|
|
44
|
+
} from "../mega-config.js";
|
|
45
|
+
import { Dashboard, type DashboardSnapshot } from "../mega-dashboard.js";
|
|
46
|
+
import {
|
|
47
|
+
STATUS_KEY,
|
|
48
|
+
WIDGET_KEY,
|
|
49
|
+
TOKENS_PER_SEC_ESTIMATE,
|
|
50
|
+
ownVersion,
|
|
51
|
+
type SessionRuntime,
|
|
52
|
+
} from "./helpers.js";
|
|
53
|
+
import {
|
|
54
|
+
C,
|
|
55
|
+
buildWidgetLines,
|
|
56
|
+
type TickerEntry,
|
|
57
|
+
type WidgetData,
|
|
58
|
+
} from "./widget.js";
|
|
59
|
+
import { getTheme } from "../../src/config/themes.js";
|
|
60
|
+
import { watch, type FSWatcher } from "node:fs";
|
|
61
|
+
import { turnLevel } from "../../src/game/scoring.js";
|
|
62
|
+
|
|
63
|
+
export class MegaRuntime {
|
|
64
|
+
config: MegaConfig;
|
|
65
|
+
// Store/dashboard/logger are rebound per-repo by bindRepo() so each git repo
|
|
66
|
+
// gets its own isolated state dir. They start bound to the global default.
|
|
67
|
+
store: VectorStore;
|
|
68
|
+
logger: Logger;
|
|
69
|
+
dashboard: Dashboard;
|
|
70
|
+
activeRepoRoot: string | null = null;
|
|
71
|
+
currentStateDir: string;
|
|
72
|
+
|
|
73
|
+
// The only mutable per-session state. Reset on session_start / session_tree.
|
|
74
|
+
rt: SessionRuntime = {
|
|
75
|
+
sessionId: normalizeSessionId(undefined),
|
|
76
|
+
persistedThisSession: false,
|
|
77
|
+
lastCheckpointId: undefined,
|
|
78
|
+
lastCompactedFrom: 0,
|
|
79
|
+
lastCompactedTokens: 0,
|
|
80
|
+
dedupSkips: 0,
|
|
81
|
+
dedupAttempts: 0,
|
|
82
|
+
tokensSaved: 0,
|
|
83
|
+
lastCompactAt: null,
|
|
84
|
+
lastNativeCompactAt: null,
|
|
85
|
+
compactCount: 0,
|
|
86
|
+
recallInjections: 0,
|
|
87
|
+
cacheHitTokens: 0,
|
|
88
|
+
lengthStopPending: false,
|
|
89
|
+
};
|
|
90
|
+
debounceUntil = 0;
|
|
91
|
+
// S16: debounce for the agent_end resume nudge (avoid busy-loops).
|
|
92
|
+
resumeNudgeUntil = 0;
|
|
93
|
+
// Agent tracking for real-time widget updates
|
|
94
|
+
activeAgents = 0;
|
|
95
|
+
currentTurn = 0;
|
|
96
|
+
// S33: transient MEGA CACHE flare flag (armed by the turn_end scoring hook
|
|
97
|
+
// when cachePct > 100). Copied into widgetData.megaCacheFlare on the next
|
|
98
|
+
// snapshot() so the widget renders the oopsie gag, then reset (one cycle).
|
|
99
|
+
megaCacheFlare = false;
|
|
100
|
+
megaCacheFlarePct = 0;
|
|
101
|
+
levelUpFlare = false;
|
|
102
|
+
lastLevel = 0;
|
|
103
|
+
// S35: transient achievement-unlock flare (armed by the scoring hooks after
|
|
104
|
+
// evaluateAndUnlockAchievements returns newly-unlocked titles). Copied into
|
|
105
|
+
// widgetData.achievementFlare on the next snapshot() so the widget renders the
|
|
106
|
+
// unlock toast, then reset (one cycle — mirrors megaCacheFlare/levelUpFlare).
|
|
107
|
+
achievementFlare = false;
|
|
108
|
+
achievementFlareTitles: string[] = [];
|
|
109
|
+
// S33: last cumulative dedup-collapsed count seen by the session_compact
|
|
110
|
+
// hook, so we only record the DELTA as the dedupe score (leaderboard sums).
|
|
111
|
+
lastDedupCollapsed = 0;
|
|
112
|
+
// Recall block produced by auto-inline (resume/branch) that the next
|
|
113
|
+
// before_agent_start should prepend to the system prompt. Unset after use.
|
|
114
|
+
pendingRecallBlock: string | undefined;
|
|
115
|
+
// S21: memory recall block, parallel to pendingRecallBlock. Same one-shot
|
|
116
|
+
// semantics; composed with the checkpoint block in before_agent_start.
|
|
117
|
+
pendingMemoryRecallBlock: string | undefined;
|
|
118
|
+
statusKey: string | undefined; // current status text for dashboard
|
|
119
|
+
// Active model/provider (for real cost estimation). Captured from ctx.model
|
|
120
|
+
// on model_select + session_start; persisted to SQL so cost + the dashboard
|
|
121
|
+
// can read it without a live ctx.
|
|
122
|
+
currentModel: ModelSnapshot | undefined;
|
|
123
|
+
// Live "what it's doing right now" timestamp, used for the fresh-window.
|
|
124
|
+
lastActivityAt = 0;
|
|
125
|
+
// Live per-tier dedup trace (Phase 1): e.g. "L0 ✓ → L1 ✓ → L2 0.91 → stored".
|
|
126
|
+
// Built from the store's sync onTier callback during a compaction so the user
|
|
127
|
+
// watches each tier evaluate in real time. Cleared once the outcome settles.
|
|
128
|
+
tierTrace: string | undefined;
|
|
129
|
+
// Phase 3 — standout toolbar state.
|
|
130
|
+
// Recall/activity ticker: a small ring buffer (≤5) of recent compact/recall
|
|
131
|
+
// events so the widget shows a live history instead of a single last action.
|
|
132
|
+
ticker: TickerEntry[] = [];
|
|
133
|
+
readonly TICKER_MAX = 5;
|
|
134
|
+
// Pulsing status: set true while a compaction is in flight, cleared on result.
|
|
135
|
+
pulsing = false;
|
|
136
|
+
// S21.2: set by `applyMemoryOps` when a memory add/replace/remove lands in
|
|
137
|
+
// the current compaction. The pipeline reads this after a successful compact
|
|
138
|
+
// to decide whether to fire `consolidateMemories` (skip the work entirely
|
|
139
|
+
// when no memory rows changed).
|
|
140
|
+
memoriesTouchedThisCompaction = 0;
|
|
141
|
+
// Rolling "saved" goal for the progress bar — grows as we save more, so the
|
|
142
|
+
// bar always has a meaningful denominator (never sits at 100% forever).
|
|
143
|
+
savedGoal = 50_000;
|
|
144
|
+
// Last explain-why line (dedup reason / anchor-kept / superseded), surfaced
|
|
145
|
+
// while fresh.
|
|
146
|
+
lastWhy: string | undefined = undefined;
|
|
147
|
+
|
|
148
|
+
// Context tracking for the dashboard (updated in the context handler).
|
|
149
|
+
lastCtxTokens: number | null = null;
|
|
150
|
+
lastCtxPercent: number | null = null;
|
|
151
|
+
lastCtxWindow = 0;
|
|
152
|
+
|
|
153
|
+
// Latest computed widget payload (recomputed per snapshot, rendered per frame).
|
|
154
|
+
widgetData: WidgetData | null = null;
|
|
155
|
+
// Cached cross-repo drift status (recomputed at most every 30s — it opens the
|
|
156
|
+
// machine-wide registry DB, so we don't want to do it on every render frame).
|
|
157
|
+
private driftCache: { at: number; status: "ok" | "warn" } | null = null;
|
|
158
|
+
// S31: cached game-mode state (game_mode_on/theme/tui_display_mode). Lazily
|
|
159
|
+
// read from the game_state SQLite row on the first widget render, then
|
|
160
|
+
// memoized until bumpGameState() evicts it (called by /mega-game after a
|
|
161
|
+
// write) so the widget picks up theme/mode/level changes live without
|
|
162
|
+
// re-querying the DB on every render frame.
|
|
163
|
+
private cachedGameState: GameState | undefined;
|
|
164
|
+
// S32: fs.watch on the current repo's sqlite.db so cross-process writes
|
|
165
|
+
// (e.g. the dashboard server's PUT /api/game-state, which runs as a detached
|
|
166
|
+
// child with no MegaRuntime ref) evict the cached game-state memo. Without
|
|
167
|
+
// this, /mega-game's in-process bumpGameState() is the only eviction trigger
|
|
168
|
+
// and the widget would keep showing stale theme/mode/toggle after a dashboard
|
|
169
|
+
// edit until a restart. The watcher tracks currentStateDir — closed + re-opened
|
|
170
|
+
// by ensureGameStateWatcher() on every bindRepo repo switch. Non-fatal: any
|
|
171
|
+
// fs.watch failure (missing file / platform issue) is swallowed; the next
|
|
172
|
+
// getCachedGameState() snapshot re-queries the DB anyway.
|
|
173
|
+
private gameStateWatcher?: FSWatcher;
|
|
174
|
+
private gameStateWatchDir?: string;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* DIAG counters for the "team run doesn't relieve context" investigation.
|
|
178
|
+
* Plain integers, incremented at the three compaction decision points. They
|
|
179
|
+
* let a headless test drive the real event handlers and assert the firing
|
|
180
|
+
* cadence without scraping log files. Inert in production (the live-trim and
|
|
181
|
+
* before-compact probes also emit logger.info, but these counters are always
|
|
182
|
+
* updated and cost nothing).
|
|
183
|
+
*/
|
|
184
|
+
diagLiveTrimFires = 0; // context handler returned a trimmed view
|
|
185
|
+
diagBeforeCompactFires = 0; // session_before_compact handler entered
|
|
186
|
+
diagBeforeCompactSupplied = 0; // session_before_compact supplied our trim
|
|
187
|
+
diagAgentEndIdle = 0; // agent_end with activeAgents===0
|
|
188
|
+
diagAgentEndDurable = 0; // agent_end fired ctx.compact() (mid-run durable trim)
|
|
189
|
+
diagAgentEndDurableSkipRecent = 0; // agent_end skipped ctx.compact() — compaction in last 10s (race guard)
|
|
190
|
+
// Per-skip-path counters for the team-run diagnosis.
|
|
191
|
+
diagCtxFastGate = 0; // returned at token fast-gate (below threshold)
|
|
192
|
+
diagCtxNoCompact = 0; // autoCompactCheck().shouldCompact === false
|
|
193
|
+
diagCtxDebounce = 0; // debounceUntil not yet elapsed
|
|
194
|
+
diagCtxRunSkipped = 0; // runCompact() returned skipped
|
|
195
|
+
diagCtxCutNull = 0; // computeLiveTrimCut returned null (anchor/boundary)
|
|
196
|
+
diagCtxThrown = 0; // live-trim try threw (caught)
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* S26 capture instrumentation: the "model_snapshots empty → $0.00 cost card"
|
|
200
|
+
* bug was invisible because captureModel swallowed the DB write in a silent
|
|
201
|
+
* `catch {}`. These always-updated counters (zero cost) let a headless test or
|
|
202
|
+
* a live capture tell whether captureModel ran and whether the snapshot landed.
|
|
203
|
+
*/
|
|
204
|
+
diagCaptureModelCalls = 0; // captureModel entered with a populated ctx.model
|
|
205
|
+
diagCaptureModelFails = 0; // recordModelSnapshot threw → model_snapshots stays empty
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Live 0–1 pressure — how full the context window is relative to the
|
|
209
|
+
* compaction threshold.
|
|
210
|
+
*
|
|
211
|
+
* RECONCILE (BACKLOG dual-basis flicker): when the model context window is
|
|
212
|
+
* known we base pressure consistently on the *percentage* basis
|
|
213
|
+
* (`lastCtxPercent / (tierPct*100)`). This keeps the band stable whether the
|
|
214
|
+
* latest context event carried a token count or only a percentage, so the
|
|
215
|
+
* threshold comparison doesn't jump when a token-count event arrives vs a
|
|
216
|
+
* percent-only event. We only fall back to the token-count basis
|
|
217
|
+
* (`config.thresholdTokens`) when the window is unknown (e.g. before the first
|
|
218
|
+
* context event, or a `custom` tier with no tierPct). Always finite + in [0,1].
|
|
219
|
+
*/
|
|
220
|
+
get pressure(): number {
|
|
221
|
+
if (
|
|
222
|
+
this.lastCtxWindow > 0 &&
|
|
223
|
+
this.config.tierPct != null &&
|
|
224
|
+
this.lastCtxPercent != null
|
|
225
|
+
) {
|
|
226
|
+
// pressureFromPct(x) = x/100, and x = lastCtxPercent/tierPct, so this is
|
|
227
|
+
// exactly the intended lastCtxPercent/(tierPct*100) 0–1 ratio: at the
|
|
228
|
+
// fire point (lastCtxPercent == tierPct*100) pressure == 1.0, matching the
|
|
229
|
+
// token-based pressureRatio(currentTokens, effectiveThreshold) reading so
|
|
230
|
+
// the band doesn't jump when a token-count vs percent-only event arrives.
|
|
231
|
+
return pressureFromPct(this.lastCtxPercent / this.config.tierPct);
|
|
232
|
+
}
|
|
233
|
+
if (
|
|
234
|
+
this.lastCtxTokens != null &&
|
|
235
|
+
this.lastCtxTokens > 0 &&
|
|
236
|
+
this.config.thresholdTokens > 0
|
|
237
|
+
) {
|
|
238
|
+
return pressureRatio(this.lastCtxTokens, this.config.thresholdTokens);
|
|
239
|
+
}
|
|
240
|
+
return pressureFromPct(this.lastCtxPercent);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The live compaction FIRE POINT in tokens: the effective threshold scaled by
|
|
245
|
+
* the current model context window (`tierPct * window`) when known, else the
|
|
246
|
+
* boot fallback `config.thresholdTokens`. This is what the FAST GATE /
|
|
247
|
+
* `autoCompactCheck` / agent_end durable-trigger compare against, so
|
|
248
|
+
* compaction fires at tier% of the window for ANY model size (200k or 1M),
|
|
249
|
+
* always below pi's native auto-compaction (~80% of window).
|
|
250
|
+
*/
|
|
251
|
+
get effectiveThreshold(): number {
|
|
252
|
+
return effectiveThresholdTokens({
|
|
253
|
+
tierPct: this.config.tierPct,
|
|
254
|
+
fallbackThreshold: this.config.thresholdTokens,
|
|
255
|
+
window: this.lastCtxWindow,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Live discrete pressure band (low/medium/high/ultra/mega) over `pressure`. */
|
|
260
|
+
get pressureBand(): PressureBand {
|
|
261
|
+
return pressureBand(this.pressure);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
constructor(config: MegaConfig) {
|
|
265
|
+
this.config = config;
|
|
266
|
+
this.store = new VectorStore({
|
|
267
|
+
dedupSim: config.dedupSim,
|
|
268
|
+
stateDir: config.stateDir,
|
|
269
|
+
});
|
|
270
|
+
this.logger = new Logger({
|
|
271
|
+
enabled: config.debug,
|
|
272
|
+
path: join(config.stateDir, "mega-compact.log"),
|
|
273
|
+
});
|
|
274
|
+
this.dashboard = new Dashboard(config.stateDir);
|
|
275
|
+
this.currentStateDir = config.stateDir;
|
|
276
|
+
this.ensureGameStateWatcher();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---- per-repo binding -----------------------------------------------------
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Point store/dashboard/logger at the current repo's state dir. Rebuilds the
|
|
283
|
+
* instances only when the repo root changes, so cross-repo dedup stats, db,
|
|
284
|
+
* and events are fully isolated. Falls back to the global default outside git.
|
|
285
|
+
*/
|
|
286
|
+
bindRepo(cwd: string | undefined): string {
|
|
287
|
+
const dir = cwd
|
|
288
|
+
? repoStateDir(cwd, this.config.stateDir)
|
|
289
|
+
: this.config.stateDir;
|
|
290
|
+
const key = cwd ? (resolveRepoRoot(cwd) ?? dir) : dir;
|
|
291
|
+
if (key === this.activeRepoRoot) return dir;
|
|
292
|
+
this.activeRepoRoot = key;
|
|
293
|
+
this.currentStateDir = dir;
|
|
294
|
+
// S31 audit P2: bindRepo switched currentStateDir but left cachedGameState
|
|
295
|
+
// memoized -> the widget kept showing the previous repo's theme/mode/toggle
|
|
296
|
+
// until /mega-game or a restart. The game_state row is per-repo (per
|
|
297
|
+
// stateDir), so evict the memo on every repo switch; the next widget render
|
|
298
|
+
// re-queries lazily via getCachedGameState().
|
|
299
|
+
this.cachedGameState = undefined;
|
|
300
|
+
// S32: re-target the fs.watch cache-eviction watcher at the NEW stateDir's
|
|
301
|
+
// sqlite.db so cross-process writes (dashboard server) still evict the memo.
|
|
302
|
+
this.ensureGameStateWatcher();
|
|
303
|
+
this.store = new VectorStore({
|
|
304
|
+
dedupSim: this.config.dedupSim,
|
|
305
|
+
stateDir: dir,
|
|
306
|
+
});
|
|
307
|
+
this.logger = new Logger({
|
|
308
|
+
enabled: this.config.debug,
|
|
309
|
+
path: join(dir, "mega-compact.log"),
|
|
310
|
+
});
|
|
311
|
+
this.dashboard = new Dashboard(dir);
|
|
312
|
+
// Aggregate this repo into the machine-wide index so the multi-repo
|
|
313
|
+
// dashboard (Summary / All-repos tabs) can show it alongside every other
|
|
314
|
+
// repo. Best-effort + non-fatal: a read-only index dir or contention must
|
|
315
|
+
// never break the per-repo compaction path. Runs only on repo-switch
|
|
316
|
+
// (this branch), so it's infrequent — not per-context-event.
|
|
317
|
+
try {
|
|
318
|
+
const repo = this.store.repoStats();
|
|
319
|
+
const di = this.store.dataInvariant();
|
|
320
|
+
const root = key !== dir ? key : (resolveRepoRoot(cwd ?? dir) ?? dir);
|
|
321
|
+
upsertRepoRegistry({
|
|
322
|
+
repoRoot: root,
|
|
323
|
+
displayName: root.split(/[\\/]/).filter(Boolean).pop() ?? root,
|
|
324
|
+
stateDir: dir,
|
|
325
|
+
checkpointCount: repo.checkpointCount,
|
|
326
|
+
tokensSaved: repo.tokensSaved,
|
|
327
|
+
compressedOriginalBytes: di.compressedOriginalBytes,
|
|
328
|
+
});
|
|
329
|
+
} catch {
|
|
330
|
+
/* non-fatal: index aggregation must not block compaction */
|
|
331
|
+
}
|
|
332
|
+
return dir;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ---- dashboard snapshot + widget ------------------------------------------
|
|
336
|
+
|
|
337
|
+
/** Collect live state and write it to disk (+ paint the above-editor widget). */
|
|
338
|
+
snapshot(ctx?: ExtensionContext): void {
|
|
339
|
+
if (ctx) this.bindRepo(ctx.cwd);
|
|
340
|
+
const st = this.store.stats(this.rt.sessionId);
|
|
341
|
+
const repo = this.store.repoStats();
|
|
342
|
+
const di = this.store.dataInvariant();
|
|
343
|
+
// Live + store-wide cache-hit / compaction counters for the dashboard.
|
|
344
|
+
const ds = getDedupStats(this.currentStateDir);
|
|
345
|
+
const cacheHitsTotal = ds.deduped + getRecallInjected(this.currentStateDir);
|
|
346
|
+
const cacheHitsTotalTokens = getCacheHitTokensSaved(this.currentStateDir);
|
|
347
|
+
const cacheHitsSession = this.rt.dedupSkips + this.rt.recallInjections;
|
|
348
|
+
const sec = (tok: number) => (tok || 0) / TOKENS_PER_SEC_ESTIMATE;
|
|
349
|
+
// Active model/provider for the current-repo card + the multi-repo table.
|
|
350
|
+
const modelSnap = latestModelSnapshot(this.currentStateDir);
|
|
351
|
+
const model = modelSnap
|
|
352
|
+
? {
|
|
353
|
+
name: modelSnap.modelName ?? modelSnap.modelId,
|
|
354
|
+
provider: modelSnap.provider,
|
|
355
|
+
providerName: modelSnap.providerName ?? "",
|
|
356
|
+
inputRate: modelSnap.inputRate,
|
|
357
|
+
outputRate: modelSnap.outputRate,
|
|
358
|
+
}
|
|
359
|
+
: undefined;
|
|
360
|
+
// effectiveThresholdPct: the live fire point as a % of the window (null for
|
|
361
|
+
// `custom`, which has no tierPct). S29: honors MEGACOMPACT_AUTO_PCT_TRIGGER
|
|
362
|
+
// override so the dashboard's armed/ready match the context-handler gate
|
|
363
|
+
// (which fires on this same %). Used by armed/ready + the dashboard.
|
|
364
|
+
const effectiveThresholdPct =
|
|
365
|
+
this.config.tierPct != null
|
|
366
|
+
? (this.config.autoPctTrigger ?? this.config.tierPct) * 100
|
|
367
|
+
: null;
|
|
368
|
+
// armed lights at/above the REAL fire point: max(effectiveThresholdPct,
|
|
369
|
+
// fastGatePct). fastGatePct already equals tierPct*100 by default, but a
|
|
370
|
+
// MEGACOMPACT_FAST_GATE_PCT override can raise it, so we take the max.
|
|
371
|
+
const armed =
|
|
372
|
+
this.lastCtxPercent != null &&
|
|
373
|
+
this.lastCtxPercent >=
|
|
374
|
+
Math.max(effectiveThresholdPct ?? 0, this.config.fastGatePct);
|
|
375
|
+
// S29: ready mirrors the context-handler gate's basis — percent for tiered
|
|
376
|
+
// (the gate fires on pct), tokens for custom (the gate fires on tokens).
|
|
377
|
+
// Previously this always required tokens, so the dashboard could show
|
|
378
|
+
// "armed" (percent high) but never "ready" when tokens were under-reported
|
|
379
|
+
// — the same inconsistency the S29 gate fix removes.
|
|
380
|
+
const ready =
|
|
381
|
+
this.config.tierPct != null
|
|
382
|
+
? armed && (this.lastCtxPercent ?? 0) >= (effectiveThresholdPct ?? 0)
|
|
383
|
+
: armed && (this.lastCtxTokens ?? 0) >= this.effectiveThreshold;
|
|
384
|
+
this.dashboard.snapshot({
|
|
385
|
+
version: 1,
|
|
386
|
+
updatedAt: new Date().toISOString(),
|
|
387
|
+
// S24: the headline tier is the LIVE pressure band; the env preset is kept
|
|
388
|
+
// alongside as presetTier so the dashboard can show both.
|
|
389
|
+
tier: this.pressureBand,
|
|
390
|
+
presetTier: this.config.tier,
|
|
391
|
+
pressure: this.pressure,
|
|
392
|
+
config: {
|
|
393
|
+
fastGatePct: this.config.fastGatePct,
|
|
394
|
+
thresholdTokens: this.effectiveThreshold,
|
|
395
|
+
tierPct: this.config.tierPct,
|
|
396
|
+
effectiveThresholdPct,
|
|
397
|
+
anchorUserMessages: this.config.anchorUserMessages,
|
|
398
|
+
preserveRecent: this.config.preserveRecent,
|
|
399
|
+
auto: this.config.auto,
|
|
400
|
+
autoInline: this.config.autoInline,
|
|
401
|
+
},
|
|
402
|
+
session: {
|
|
403
|
+
id: this.rt.sessionId,
|
|
404
|
+
state: this.statusKey ?? "idle",
|
|
405
|
+
persistedThisSession: this.rt.persistedThisSession,
|
|
406
|
+
lastCheckpointId: this.rt.lastCheckpointId ?? null,
|
|
407
|
+
lastCompactedFrom: this.rt.lastCompactedFrom,
|
|
408
|
+
lastCompactedTokens: this.rt.lastCompactedTokens,
|
|
409
|
+
dedupSkips: this.rt.dedupSkips,
|
|
410
|
+
dedupAttempts: this.rt.dedupAttempts,
|
|
411
|
+
},
|
|
412
|
+
context: {
|
|
413
|
+
tokens: this.lastCtxTokens,
|
|
414
|
+
percent: this.lastCtxPercent,
|
|
415
|
+
contextWindow: this.lastCtxWindow,
|
|
416
|
+
},
|
|
417
|
+
trigger: {
|
|
418
|
+
armed,
|
|
419
|
+
ready,
|
|
420
|
+
currentTokens: this.lastCtxTokens,
|
|
421
|
+
thresholdTokens: this.effectiveThreshold,
|
|
422
|
+
fastGatePct: this.config.fastGatePct,
|
|
423
|
+
tierPct: this.config.tierPct,
|
|
424
|
+
effectiveThresholdPct,
|
|
425
|
+
},
|
|
426
|
+
crew: { activeAgents: this.activeAgents, currentTurn: this.currentTurn },
|
|
427
|
+
store: {
|
|
428
|
+
checkpointCount: st.checkpointCount,
|
|
429
|
+
totalTokenEstimate: st.totalTokenEstimate,
|
|
430
|
+
originalTokens: st.originalTokens,
|
|
431
|
+
tokensSaved: this.rt.tokensSaved,
|
|
432
|
+
injectedCount: st.injectedCount,
|
|
433
|
+
dedupHitRate: st.dedupHitRate,
|
|
434
|
+
storageDedupRate: st.storageDedupRate,
|
|
435
|
+
dedupAttempts: st.dedupAttempts,
|
|
436
|
+
dedupCollapsed: st.dedupCollapsed,
|
|
437
|
+
},
|
|
438
|
+
// Reconciled token accounting (single canonical formula, session + repo).
|
|
439
|
+
// Freed = In − Out; In = Freed + Out. session.Freed = rt.tokensSaved (incl.
|
|
440
|
+
// deduped-away originals); repo.Freed = repo.tokensSaved meta counter.
|
|
441
|
+
compression: {
|
|
442
|
+
session: {
|
|
443
|
+
tokensIn: this.rt.tokensSaved + st.totalTokenEstimate,
|
|
444
|
+
tokensOut: st.totalTokenEstimate,
|
|
445
|
+
tokensFreed: this.rt.tokensSaved,
|
|
446
|
+
compressionPct:
|
|
447
|
+
this.rt.tokensSaved + st.totalTokenEstimate > 0
|
|
448
|
+
? this.rt.tokensSaved /
|
|
449
|
+
(this.rt.tokensSaved + st.totalTokenEstimate)
|
|
450
|
+
: 0,
|
|
451
|
+
dedupPct: st.storageDedupRate,
|
|
452
|
+
},
|
|
453
|
+
repo: {
|
|
454
|
+
tokensIn: repo.tokensSaved + repo.totalTokenEstimate,
|
|
455
|
+
tokensOut: repo.totalTokenEstimate,
|
|
456
|
+
tokensFreed: repo.tokensSaved,
|
|
457
|
+
compressionPct:
|
|
458
|
+
repo.tokensSaved + repo.totalTokenEstimate > 0
|
|
459
|
+
? repo.tokensSaved / (repo.tokensSaved + repo.totalTokenEstimate)
|
|
460
|
+
: 0,
|
|
461
|
+
dedupPct: repo.storageDedupRate,
|
|
462
|
+
},
|
|
463
|
+
},
|
|
464
|
+
repo: {
|
|
465
|
+
checkpointCount: repo.checkpointCount,
|
|
466
|
+
totalTokenEstimate: repo.totalTokenEstimate,
|
|
467
|
+
originalTokens: repo.originalTokens,
|
|
468
|
+
tokensSaved: repo.tokensSaved,
|
|
469
|
+
sessionCount: repo.sessionCount,
|
|
470
|
+
dedupAttempts: repo.dedupAttempts,
|
|
471
|
+
dedupCollapsed: repo.dedupCollapsed,
|
|
472
|
+
storageDedupRate: repo.storageDedupRate,
|
|
473
|
+
},
|
|
474
|
+
integrity: {
|
|
475
|
+
regionsRetained: di.regionsRetained,
|
|
476
|
+
compressedOriginalBytes: di.compressedOriginalBytes,
|
|
477
|
+
duplicatesCollapsed: di.duplicatesCollapsed,
|
|
478
|
+
bytesPermanentlyDeleted: di.bytesPermanentlyDeleted,
|
|
479
|
+
},
|
|
480
|
+
cacheHits: {
|
|
481
|
+
session: cacheHitsSession,
|
|
482
|
+
total: cacheHitsTotal,
|
|
483
|
+
sessionTokensSaved: this.rt.cacheHitTokens,
|
|
484
|
+
totalTokensSaved: cacheHitsTotalTokens,
|
|
485
|
+
},
|
|
486
|
+
compacts: {
|
|
487
|
+
session: this.rt.compactCount,
|
|
488
|
+
total: getCompactCount(this.currentStateDir),
|
|
489
|
+
},
|
|
490
|
+
timeSaved: {
|
|
491
|
+
compact: { sessionSec: sec(this.rt.tokensSaved), totalSec: sec(this.store.repoStats().tokensSaved) },
|
|
492
|
+
cacheHit: { sessionSec: sec(this.rt.cacheHitTokens), totalSec: sec(cacheHitsTotalTokens) },
|
|
493
|
+
},
|
|
494
|
+
model,
|
|
495
|
+
} as DashboardSnapshot);
|
|
496
|
+
|
|
497
|
+
// Live stats widget above the editor
|
|
498
|
+
if (ctx) {
|
|
499
|
+
// ── gather widget data (computed per snapshot, rendered per frame) ────
|
|
500
|
+
const tokStr =
|
|
501
|
+
this.lastCtxTokens != null
|
|
502
|
+
? `${Math.round(this.lastCtxTokens / 1000)}k`
|
|
503
|
+
: "?";
|
|
504
|
+
const maxStr =
|
|
505
|
+
this.lastCtxWindow > 0
|
|
506
|
+
? `${Math.round(this.lastCtxWindow / 1000)}k`
|
|
507
|
+
: "?";
|
|
508
|
+
const pctStr =
|
|
509
|
+
this.lastCtxPercent != null
|
|
510
|
+
? this.lastCtxPercent > 100
|
|
511
|
+
? `>100%` // S29: overshoot warning, not a raw "250%" — the percent trigger now compacts before 100%, so this is the residual case where it can't keep up.
|
|
512
|
+
: `${Math.round(this.lastCtxPercent * 10) / 10}%`
|
|
513
|
+
: "?%";
|
|
514
|
+
// S24: the tier label is the LIVE pressure band (low/medium/high/ultra/
|
|
515
|
+
// mega), not the static env preset. It climbs as context fills.
|
|
516
|
+
const liveBand = this.pressureBand;
|
|
517
|
+
const tierLabel = `${C.bold}${liveBand}${C.reset}${C.gray}·${this.config.tier}${C.reset}`;
|
|
518
|
+
const triggerLabel = ready
|
|
519
|
+
? `${C.green}● ready${C.reset}`
|
|
520
|
+
: armed
|
|
521
|
+
? `${C.amber}◐ armed${C.reset}`
|
|
522
|
+
: `${C.gray}○ idle${C.reset}`;
|
|
523
|
+
// Storage dedup rate is cumulative (store-wide, per-repo) and survives
|
|
524
|
+
// session resets. Always show a number (decimal for sub-10%).
|
|
525
|
+
const storageRate = st.storageDedupRate; // 0..1
|
|
526
|
+
const dedupStr =
|
|
527
|
+
storageRate * 100 >= 10
|
|
528
|
+
? `${Math.round(storageRate * 100)}%`
|
|
529
|
+
: `${(storageRate * 100).toFixed(1)}%`;
|
|
530
|
+
// Agents view: count + status (S27 per-agent tokens are gated on P0).
|
|
531
|
+
const agentLabel =
|
|
532
|
+
this.activeAgents > 0
|
|
533
|
+
? `🤖 ${this.activeAgents} agent${this.activeAgents === 1 ? "" : "s"}`
|
|
534
|
+
: `${C.dim}🤖 idle${C.reset}`;
|
|
535
|
+
const agentStr = ` │ ${agentLabel}`;
|
|
536
|
+
const turnStr = this.currentTurn > 0 ? ` │ turn ${this.currentTurn}` : "";
|
|
537
|
+
// Reconciled in/out view (session + repo) — ONE canonical formula.
|
|
538
|
+
const sessIn = this.rt.tokensSaved + st.totalTokenEstimate;
|
|
539
|
+
const sessKept = st.totalTokenEstimate;
|
|
540
|
+
const sessPct = sessIn > 0 ? this.rt.tokensSaved / sessIn : 0;
|
|
541
|
+
const repoIn = repo.tokensSaved + repo.totalTokenEstimate;
|
|
542
|
+
const repoKept = repo.totalTokenEstimate;
|
|
543
|
+
const repoPct = repoIn > 0 ? repo.tokensSaved / repoIn : 0;
|
|
544
|
+
const sTxt = (sessPct * 100).toFixed(sessPct * 100 >= 10 ? 0 : 1);
|
|
545
|
+
const rTxt = (repoPct * 100).toFixed(repoPct * 100 >= 10 ? 0 : 1);
|
|
546
|
+
const ctxPct =
|
|
547
|
+
this.lastCtxPercent != null ? this.lastCtxPercent / 100 : 0;
|
|
548
|
+
// Model + provider (S26 capture) for the header.
|
|
549
|
+
const modelName = modelSnap?.modelName ?? modelSnap?.modelId ?? "?";
|
|
550
|
+
const modelStr = modelSnap?.provider
|
|
551
|
+
? `${modelName}·${modelSnap.provider}`
|
|
552
|
+
: modelName;
|
|
553
|
+
// Since-last-compact (ms; null until first compaction this session).
|
|
554
|
+
const sinceCompact =
|
|
555
|
+
this.rt.lastCompactAt != null
|
|
556
|
+
? Date.now() - this.rt.lastCompactAt
|
|
557
|
+
: null;
|
|
558
|
+
// Memory store: embedder + compression ratio (original / stored).
|
|
559
|
+
const embedderName = this.embedderName();
|
|
560
|
+
const compRatio =
|
|
561
|
+
st.originalTokens > 0 && st.totalTokenEstimate > 0
|
|
562
|
+
? st.originalTokens / st.totalTokenEstimate
|
|
563
|
+
: st.originalTokens > 0
|
|
564
|
+
? 1
|
|
565
|
+
: 0;
|
|
566
|
+
const compStr = compRatio >= 1 ? `${compRatio.toFixed(1)}x` : "—";
|
|
567
|
+
// Cross-repo drift status (cached, read-only).
|
|
568
|
+
const driftStatus = this.driftStatus();
|
|
569
|
+
const agentsActive = this.activeAgents > 0;
|
|
570
|
+
|
|
571
|
+
// S31: game-mode state for the widget (theme/mode/level + MEGA CACHE).
|
|
572
|
+
// Pulled from the cached game_state row; cachePct is the REAL dedup hit
|
|
573
|
+
// rate (may exceed 100% — that's the MEGA CACHE trigger). megaCacheFlare
|
|
574
|
+
// is false for now (S33.4 scoring hook arms it when cachePct > 100).
|
|
575
|
+
const gs = this.getCachedGameState();
|
|
576
|
+
// S34: derive the level-up flare from the turn count each snapshot.
|
|
577
|
+
const curLevel = this.getTurnLevel();
|
|
578
|
+
if (curLevel > this.lastLevel) this.levelUpFlare = true;
|
|
579
|
+
const cachePct = st.dedupHitRate * 100;
|
|
580
|
+
this.widgetData = {
|
|
581
|
+
version: ownVersion(),
|
|
582
|
+
tierLabel,
|
|
583
|
+
triggerLabel,
|
|
584
|
+
pctStr,
|
|
585
|
+
tokStr,
|
|
586
|
+
maxStr,
|
|
587
|
+
ctxPct,
|
|
588
|
+
chk: st.checkpointCount,
|
|
589
|
+
agentStr,
|
|
590
|
+
turnStr,
|
|
591
|
+
dedupStr,
|
|
592
|
+
sessIn,
|
|
593
|
+
sessKept,
|
|
594
|
+
sTxt,
|
|
595
|
+
repoIn,
|
|
596
|
+
repoKept,
|
|
597
|
+
rTxt,
|
|
598
|
+
repoChk: repo.checkpointCount,
|
|
599
|
+
repoSess: repo.sessionCount,
|
|
600
|
+
modelStr,
|
|
601
|
+
sinceCompact,
|
|
602
|
+
embedderName,
|
|
603
|
+
compStr,
|
|
604
|
+
driftStatus,
|
|
605
|
+
agentsActive,
|
|
606
|
+
fresh: Date.now() - this.lastActivityAt < 4000,
|
|
607
|
+
ticker: this.ticker,
|
|
608
|
+
lastWhy: this.lastWhy,
|
|
609
|
+
tierTrace: this.tierTrace,
|
|
610
|
+
pulsing: this.pulsing,
|
|
611
|
+
// S31 game-mode fields:
|
|
612
|
+
gameMode: gs.game_mode_on,
|
|
613
|
+
theme: getTheme(gs.theme) ? gs.theme : "transparent",
|
|
614
|
+
tuiMode: gs.tui_display_mode,
|
|
615
|
+
level: this.getTurnLevel(),
|
|
616
|
+
cachePct,
|
|
617
|
+
megaCacheFlare: this.megaCacheFlare,
|
|
618
|
+
megaCacheFlarePct: this.megaCacheFlarePct,
|
|
619
|
+
levelUpFlare: this.levelUpFlare,
|
|
620
|
+
achievementFlare: this.achievementFlare,
|
|
621
|
+
achievementFlareTitles: this.achievementFlareTitles,
|
|
622
|
+
};
|
|
623
|
+
// S33: consume the flare after copying it into widgetData so it fires
|
|
624
|
+
// for exactly one render cycle (the gag flares once, then clears).
|
|
625
|
+
this.megaCacheFlare = false;
|
|
626
|
+
this.megaCacheFlarePct = 0;
|
|
627
|
+
|
|
628
|
+
// S34: consume the level-up flare after one render cycle (mirrors the
|
|
629
|
+
// megaCacheFlare one-shot semantics), and advance lastLevel.
|
|
630
|
+
this.levelUpFlare = false;
|
|
631
|
+
this.lastLevel = curLevel;
|
|
632
|
+
// S35: consume the achievement-unlock flare after one render cycle
|
|
633
|
+
// (mirrors the megaCacheFlare/levelUpFlare one-shot semantics).
|
|
634
|
+
this.achievementFlare = false;
|
|
635
|
+
this.achievementFlareTitles = [];
|
|
636
|
+
// Auto-fit: register a factory so pi re-renders the panel at the REAL
|
|
637
|
+
// terminal width every frame (tui.columns), instead of guessing with
|
|
638
|
+
// process.stdout.columns. buildWidgetLines reads this.widgetData live.
|
|
639
|
+
this.renderWidget(ctx);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** Register the above-editor widget as a width-aware factory so pi re-renders
|
|
644
|
+
* it at the REAL terminal width every frame (auto-fit wide/narrow). The
|
|
645
|
+
* factory returns a minimal Component whose render() reads this.widgetData.
|
|
646
|
+
*/
|
|
647
|
+
private renderWidget(ctx: ExtensionContext): void {
|
|
648
|
+
ctx.ui.setWidget(
|
|
649
|
+
WIDGET_KEY,
|
|
650
|
+
(_tui, _theme) => ({
|
|
651
|
+
render: (width: number) =>
|
|
652
|
+
buildWidgetLines(
|
|
653
|
+
this.widgetData,
|
|
654
|
+
width > 0 ? width : 200,
|
|
655
|
+
this.activeAgents,
|
|
656
|
+
),
|
|
657
|
+
invalidate: () => {},
|
|
658
|
+
}),
|
|
659
|
+
{ placement: "aboveEditor" },
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Active embedder name for the memory-store line (Trigram default / MiniLM). */
|
|
664
|
+
private embedderName(): string {
|
|
665
|
+
// MINILM_EMBEDDER flag lives in src/config/dedup.ts; read the same env var
|
|
666
|
+
// the embedder factory uses so the label matches what's actually running.
|
|
667
|
+
return process.env.MEGACOMPACT_MINILM === "true" ||
|
|
668
|
+
process.env.MEGACOMPACT_MINILM === "1"
|
|
669
|
+
? "MiniLM"
|
|
670
|
+
: "Trigram";
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Cross-repo drift status (ok | warn), cached for 30s (opens the registry DB). */
|
|
674
|
+
private driftStatus(): "ok" | "warn" {
|
|
675
|
+
const now = Date.now();
|
|
676
|
+
if (this.driftCache && now - this.driftCache.at < 30_000)
|
|
677
|
+
return this.driftCache.status;
|
|
678
|
+
let status: "ok" | "warn" = "ok";
|
|
679
|
+
try {
|
|
680
|
+
const report = detectCrossRepoDrift();
|
|
681
|
+
status = report.totals.warn > 0 ? "warn" : "ok";
|
|
682
|
+
} catch {
|
|
683
|
+
status = "ok";
|
|
684
|
+
}
|
|
685
|
+
this.driftCache = { at: now, status };
|
|
686
|
+
return status;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
setStatus(ctx: ExtensionContext, text: string | undefined): void {
|
|
690
|
+
this.statusKey = text;
|
|
691
|
+
ctx.ui.setStatus(STATUS_KEY, text);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
resetRuntime(sessionId: string | undefined): void {
|
|
695
|
+
const sid = normalizeSessionId(sessionId);
|
|
696
|
+
if (this.rt.sessionId === sid && this.rt.persistedThisSession) return; // same session, keep checkpoint memory
|
|
697
|
+
this.rt = {
|
|
698
|
+
sessionId: sid,
|
|
699
|
+
persistedThisSession: false,
|
|
700
|
+
lastCheckpointId: undefined,
|
|
701
|
+
lastCompactedFrom: 0,
|
|
702
|
+
lastCompactedTokens: 0,
|
|
703
|
+
dedupSkips: 0,
|
|
704
|
+
dedupAttempts: 0,
|
|
705
|
+
tokensSaved: 0,
|
|
706
|
+
lastCompactAt: null,
|
|
707
|
+
lastNativeCompactAt: null,
|
|
708
|
+
compactCount: 0,
|
|
709
|
+
recallInjections: 0,
|
|
710
|
+
cacheHitTokens: 0,
|
|
711
|
+
lengthStopPending: false,
|
|
712
|
+
};
|
|
713
|
+
this.statusKey = undefined;
|
|
714
|
+
this.activeAgents = 0;
|
|
715
|
+
this.currentTurn = 0;
|
|
716
|
+
this.lastActivityAt = 0;
|
|
717
|
+
this.tierTrace = undefined;
|
|
718
|
+
this.ticker.length = 0;
|
|
719
|
+
this.pulsing = false;
|
|
720
|
+
this.savedGoal = 50_000;
|
|
721
|
+
this.lastWhy = undefined;
|
|
722
|
+
// S31 audit P2: symmetry with bindRepo — a reset can coincide with a context
|
|
723
|
+
// that re-binds the repo, so drop the memo too. Cheap; the next
|
|
724
|
+
// getCachedGameState() re-queries lazily.
|
|
725
|
+
this.cachedGameState = undefined;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Capture the active model/provider from ctx.model and persist it so cost
|
|
730
|
+
* estimation + the dashboard can read real pricing. Cheap + idempotent-ish:
|
|
731
|
+
* only writes a new row when the model id changes (models change rarely).
|
|
732
|
+
*/
|
|
733
|
+
captureModel(ctx: ExtensionContext): void {
|
|
734
|
+
const m = ctx.model;
|
|
735
|
+
if (!m) {
|
|
736
|
+
this.appendEvent("captureModel:no-model", { cwd: ctx.cwd });
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (
|
|
740
|
+
this.currentModel &&
|
|
741
|
+
this.currentModel.modelId === m.id &&
|
|
742
|
+
this.currentModel.provider === m.provider
|
|
743
|
+
)
|
|
744
|
+
return;
|
|
745
|
+
let providerName: string | null = null;
|
|
746
|
+
try {
|
|
747
|
+
providerName =
|
|
748
|
+
ctx.modelRegistry?.getProviderDisplayName(m.provider) ?? null;
|
|
749
|
+
} catch {
|
|
750
|
+
/* optional */
|
|
751
|
+
}
|
|
752
|
+
const snap: Omit<ModelSnapshot, "capturedAt"> = {
|
|
753
|
+
provider: m.provider,
|
|
754
|
+
providerName,
|
|
755
|
+
modelId: m.id,
|
|
756
|
+
modelName: m.name ?? null,
|
|
757
|
+
inputRate: m.cost?.input ?? 0,
|
|
758
|
+
outputRate: m.cost?.output ?? 0,
|
|
759
|
+
contextWindow: m.contextWindow ?? 0,
|
|
760
|
+
maxTokens: m.maxTokens ?? 0,
|
|
761
|
+
reasoning: !!m.reasoning,
|
|
762
|
+
};
|
|
763
|
+
this.currentModel = { ...snap, capturedAt: Date.now() };
|
|
764
|
+
this.diagCaptureModelCalls++;
|
|
765
|
+
const repo = resolveRepoRoot(ctx.cwd) ?? this.currentStateDir;
|
|
766
|
+
// S26: previously a single silent `catch {}` hid every capture failure, so
|
|
767
|
+
// model_snapshots stayed empty and the cost card read $0.00 with zero signal.
|
|
768
|
+
// Split per-write + append to events.log (always-on, dashboard live-streams
|
|
769
|
+
// it) + bump a DIAG counter so a live capture surfaces the root cause.
|
|
770
|
+
try {
|
|
771
|
+
recordModelSnapshot(repo, snap, this.currentStateDir);
|
|
772
|
+
this.appendEvent("captureModel:recorded", {
|
|
773
|
+
repo,
|
|
774
|
+
modelId: snap.modelId,
|
|
775
|
+
provider: snap.provider,
|
|
776
|
+
inputRate: snap.inputRate,
|
|
777
|
+
outputRate: snap.outputRate,
|
|
778
|
+
});
|
|
779
|
+
} catch (e) {
|
|
780
|
+
this.diagCaptureModelFails++;
|
|
781
|
+
this.appendEvent("captureModel:record-failed", {
|
|
782
|
+
repo,
|
|
783
|
+
modelId: snap.modelId,
|
|
784
|
+
error: e instanceof Error ? e.message : String(e),
|
|
785
|
+
stack: e instanceof Error ? e.stack : undefined,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
try {
|
|
789
|
+
// Denormalize the active model into the machine-wide index so the
|
|
790
|
+
// All-repos dashboard table can show provider/model per repo without
|
|
791
|
+
// opening every repo's DB. Best-effort + non-fatal.
|
|
792
|
+
recordRepoModel(repo, {
|
|
793
|
+
provider: snap.provider,
|
|
794
|
+
providerName: snap.providerName,
|
|
795
|
+
modelName: snap.modelName,
|
|
796
|
+
inputRate: snap.inputRate,
|
|
797
|
+
outputRate: snap.outputRate,
|
|
798
|
+
stateDir: this.currentStateDir,
|
|
799
|
+
displayName: repo.split(/[\\/]/).filter(Boolean).pop() ?? repo,
|
|
800
|
+
});
|
|
801
|
+
} catch (e) {
|
|
802
|
+
this.appendEvent("captureModel:index-record-failed", {
|
|
803
|
+
repo,
|
|
804
|
+
modelId: snap.modelId,
|
|
805
|
+
error: e instanceof Error ? e.message : String(e),
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* Append a structured line to the repo's events.log — the always-on
|
|
812
|
+
* diagnostics sink the dashboard live-streams. Unlike this.logger (gated by
|
|
813
|
+
* config.debug), this fires in production, so capture failures surface during
|
|
814
|
+
* a real capture even with debugging off. Best-effort + non-fatal.
|
|
815
|
+
*/
|
|
816
|
+
private appendEvent(event: string, fields: Record<string, unknown>): void {
|
|
817
|
+
try {
|
|
818
|
+
mkdirSync(this.currentStateDir, { recursive: true });
|
|
819
|
+
appendFileSync(
|
|
820
|
+
join(this.currentStateDir, "events.log"),
|
|
821
|
+
JSON.stringify({ ts: Date.now(), event, ...fields }) + "\n",
|
|
822
|
+
);
|
|
823
|
+
} catch {
|
|
824
|
+
/* non-fatal */
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** S21: state dir of the currently bound repo (where memories live). */
|
|
829
|
+
getStateDir(): string {
|
|
830
|
+
return this.currentStateDir;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** S32: (re)target the fs.watch cache-eviction watcher at the current
|
|
834
|
+
* stateDir's sqlite.db. Called from the constructor + every bindRepo repo
|
|
835
|
+
* switch so the watcher always tracks the NEW repo's db file. If a watcher
|
|
836
|
+
* already exists for this dir, no-op; if the dir changed, close the old one
|
|
837
|
+
* first. fs.watch can throw on a missing file / platform issues — wrapped
|
|
838
|
+
* non-fatal; the next getCachedGameState() re-queries the DB anyway. */
|
|
839
|
+
private ensureGameStateWatcher(): void {
|
|
840
|
+
if (this.gameStateWatcher && this.gameStateWatchDir === this.currentStateDir) {
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (this.gameStateWatcher) {
|
|
844
|
+
try { this.gameStateWatcher.close(); } catch { /* non-fatal */ }
|
|
845
|
+
this.gameStateWatcher = undefined;
|
|
846
|
+
this.gameStateWatchDir = undefined;
|
|
847
|
+
}
|
|
848
|
+
try {
|
|
849
|
+
this.gameStateWatcher = watch(
|
|
850
|
+
join(this.currentStateDir, "sqlite.db"),
|
|
851
|
+
() => { this.cachedGameState = undefined; },
|
|
852
|
+
);
|
|
853
|
+
this.gameStateWatchDir = this.currentStateDir;
|
|
854
|
+
} catch {
|
|
855
|
+
/* non-fatal: missing file / platform issue — next snapshot re-queries */
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/** S32: release the fs.watch game-state watcher. Called when the runtime is
|
|
860
|
+
* torn down (no existing dispose path — the process exit reclaims the fd,
|
|
861
|
+
* but explicit close is correct for any in-process reload / test reuse). */
|
|
862
|
+
dispose(): void {
|
|
863
|
+
if (this.gameStateWatcher) {
|
|
864
|
+
try { this.gameStateWatcher.close(); } catch { /* non-fatal */ }
|
|
865
|
+
this.gameStateWatcher = undefined;
|
|
866
|
+
this.gameStateWatchDir = undefined;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/** S31: the cached game-mode state (game_mode_on/theme/tui_display_mode).
|
|
871
|
+
* Lazily read from the game_state SQLite row on the first call, then
|
|
872
|
+
* memoized until `bumpGameState()` evicts it. Reading is non-throwing
|
|
873
|
+
* (getGameState returns DEFAULT_GAME_STATE on any error), so the widget
|
|
874
|
+
* can call this on every render safely. */
|
|
875
|
+
getCachedGameState(): GameState {
|
|
876
|
+
if (!this.cachedGameState) {
|
|
877
|
+
try {
|
|
878
|
+
this.cachedGameState = getGameState(this.currentStateDir);
|
|
879
|
+
} catch {
|
|
880
|
+
this.cachedGameState = {
|
|
881
|
+
game_mode_on: false,
|
|
882
|
+
theme: "transparent",
|
|
883
|
+
tui_display_mode: "full",
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return this.cachedGameState;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** S31: evict the cached game-mode state so the next widget render re-reads
|
|
891
|
+
* the game_state row. Called by /mega-game after every setGameState() so
|
|
892
|
+
* the panel picks up theme/mode/toggle changes live. */
|
|
893
|
+
bumpGameState(): void {
|
|
894
|
+
this.cachedGameState = undefined;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** S33: player level for game mode — floor(log2(turns+1))+1 (gentle).
|
|
898
|
+
* Defensive: non-finite/negative collapses to 1 (never NaN). */
|
|
899
|
+
private getTurnLevel(): number {
|
|
900
|
+
return turnLevel(this.currentTurn);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** S33: arm the transient MEGA CACHE flare so the next snapshot() copies it
|
|
904
|
+
* into widgetData and the widget renders the oopsie gag for one cycle. */
|
|
905
|
+
armMegaCacheFlare(peakPct: number): void {
|
|
906
|
+
this.megaCacheFlare = true;
|
|
907
|
+
this.megaCacheFlarePct = peakPct;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** S35: arm the transient achievement-unlock flare with the newly-unlocked
|
|
911
|
+
* titles so the next snapshot() copies them into widgetData and the widget
|
|
912
|
+
* renders the one-time unlock toast for one render cycle. */
|
|
913
|
+
armAchievementFlare(titles: string[]): void {
|
|
914
|
+
this.achievementFlare = true;
|
|
915
|
+
this.achievementFlareTitles = titles;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
/** Build the sync onTier callback that paints the live per-tier trace. */
|
|
919
|
+
makeTierCallback(
|
|
920
|
+
ctx: ExtensionContext,
|
|
921
|
+
): (ev: {
|
|
922
|
+
tier: "L0" | "L1" | "L2" | "new";
|
|
923
|
+
status: "scanning" | "deduped" | "passed" | "stored";
|
|
924
|
+
detail?: string;
|
|
925
|
+
}) => void {
|
|
926
|
+
const order: Array<"L0" | "L1" | "L2" | "new"> = ["L0", "L1", "L2", "new"];
|
|
927
|
+
const seen = new Map<string, string>();
|
|
928
|
+
const glyph = (status: string) =>
|
|
929
|
+
status === "deduped"
|
|
930
|
+
? `${C.green}✓${C.reset}`
|
|
931
|
+
: status === "passed"
|
|
932
|
+
? `${C.dim}○${C.reset}`
|
|
933
|
+
: status === "scanning"
|
|
934
|
+
? `${C.amber}…${C.reset}`
|
|
935
|
+
: `${C.cyan}●${C.reset}`;
|
|
936
|
+
return (ev) => {
|
|
937
|
+
const label =
|
|
938
|
+
ev.tier === "new"
|
|
939
|
+
? `${C.cyan}stored${C.reset}`
|
|
940
|
+
: `${ev.tier} ${glyph(ev.status)}` +
|
|
941
|
+
(ev.detail ? ` ${C.gray}(${ev.detail})${C.reset}` : "");
|
|
942
|
+
// Show the most recent outcome per tier (collapses re-fires).
|
|
943
|
+
seen.set(ev.tier, label);
|
|
944
|
+
const show: string[] = [];
|
|
945
|
+
for (const t of order) if (seen.has(t)) show.push(seen.get(t)!);
|
|
946
|
+
this.tierTrace = `${C.teal}⚙${C.reset} ${show.join(` ${C.gray}→${C.reset} `)}`;
|
|
947
|
+
this.lastActivityAt = Date.now();
|
|
948
|
+
try {
|
|
949
|
+
this.snapshot(ctx);
|
|
950
|
+
} catch {
|
|
951
|
+
/* non-fatal */
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Phase 3 — recall/activity ticker ring buffer.
|
|
957
|
+
pushTicker(text: string): void {
|
|
958
|
+
this.ticker.push({ text, at: Date.now() });
|
|
959
|
+
while (this.ticker.length > this.TICKER_MAX) this.ticker.shift();
|
|
960
|
+
this.lastActivityAt = Date.now();
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** Convert the messages pi hands us in the `context` event into the engine view. */
|
|
964
|
+
engineView(messages: AgentMessage[]): ReturnType<typeof toEngineMessages> {
|
|
965
|
+
return toEngineMessages(messages);
|
|
966
|
+
}
|
|
967
|
+
}
|