pi-mega-compact 0.7.6 → 0.7.8

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.
@@ -44,6 +44,7 @@ function harness(opts = {}) {
44
44
  let statusText;
45
45
  const notifies = [];
46
46
  const compactCalls = [];
47
+ const sendUserMessages = [];
47
48
  // Minimal AgentMessage factory for the session we project into the extension.
48
49
  function msg(role, text, toolName) {
49
50
  if (role === "assistant" && toolName) {
@@ -178,7 +179,7 @@ function harness(opts = {}) {
178
179
  registerMessageRenderer: () => { },
179
180
  registerEntryRenderer: () => { },
180
181
  sendMessage: (_m) => { },
181
- sendUserMessage: () => { },
182
+ sendUserMessage: (m) => { sendUserMessages.push(m); },
182
183
  appendEntry: (t, d) => appended.push({ t, d }),
183
184
  setSessionName: () => { },
184
185
  getSessionName: () => undefined,
@@ -205,6 +206,7 @@ function harness(opts = {}) {
205
206
  },
206
207
  notifies,
207
208
  compactCalls,
209
+ sendUserMessages,
208
210
  fire: (ev, event, ctx) => handlers[ev](event, ctx),
209
211
  ctx: makeCtx,
210
212
  session,
@@ -733,27 +735,6 @@ test("/dashboard skips server spawn when already running", async () => {
733
735
  await new Promise((r) => server.close(() => r()));
734
736
  delete process.env.MEGACOMPACT_DASHBOARD_PORT;
735
737
  });
736
- test("/dashboard-status reports running after dashboard start", async () => {
737
- // Private dashboard port base for this harness — never collides with the
738
- // parallel dashboard-server.test.js (9320 family) or a leftover server.
739
- process.env.MEGACOMPACT_DASHBOARD_PORT = "39320";
740
- const h = harness();
741
- const livPort = 39320;
742
- const { createServer } = await import("node:http");
743
- const { join: j } = await import("node:path");
744
- const { writeFileSync: wf } = await import("node:fs");
745
- const server = createServer((_req, res) => {
746
- res.writeHead(200, { "Content-Type": "application/json" });
747
- res.end(JSON.stringify({ updatedAt: new Date().toISOString(), tier: "test" }));
748
- });
749
- await new Promise((r) => server.listen(livPort, "127.0.0.1", r));
750
- wf(j(h.stateDir, "port.pid"), JSON.stringify({ port: livPort, pid: process.pid }));
751
- const ctx = h.ctx();
752
- await h.commands["mega-dashboard-status"].handler("", ctx);
753
- assert.ok(h.notifies.some((n) => n.includes("running") && n.includes(String(livPort))), "reports running with port");
754
- await new Promise((r) => server.close(() => r()));
755
- delete process.env.MEGACOMPACT_DASHBOARD_PORT;
756
- });
757
738
  test("state snapshot writes dashboard.json after compaction", async () => {
758
739
  const h = harness();
759
740
  const ctx = h.ctx({
@@ -806,6 +787,224 @@ test("events.log receives compaction events", async () => {
806
787
  assert.ok(ex(j(h.stateDir, "dashboard.json")), "dashboard.json proves post-compact ran");
807
788
  }
808
789
  });
790
+ test("S28: length-stop auto-continue nudges once, no ctx.compact on low-pressure length path", async () => {
791
+ const h = harness();
792
+ // Force a low-pressure context so the durable-trim branch (which calls
793
+ // ctx.compact()) is NOT taken; only the length-stop nudge should fire.
794
+ const lowPressureCtx = h.ctx({
795
+ isIdle: () => true,
796
+ hasPendingMessages: () => false,
797
+ getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
798
+ });
799
+ // 1) Normal stop: no length flag armed → no nudge.
800
+ await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message: { role: "assistant", stopReason: "stop" } }, lowPressureCtx);
801
+ await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
802
+ assert.equal(h.sendUserMessages.length, 0, "normal stop: no nudge");
803
+ assert.equal(h.compactCalls.length, 0, "normal stop: no ctx.compact");
804
+ // 2) Length stop: arms the flag, agent_end fires exactly one continue nudge
805
+ // that references the output-token truncation (not a compaction).
806
+ await h.fire("turn_end", { type: "turn_end", turnIndex: 2, message: { role: "assistant", stopReason: "length" } }, lowPressureCtx);
807
+ await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
808
+ assert.equal(h.sendUserMessages.length, 1, "length stop: exactly one nudge");
809
+ assert.match(h.sendUserMessages[0], /output-token cap/, "length stop: nudge references the output-token truncation");
810
+ assert.equal(h.compactCalls.length, 0, "length path: ctx.compact() NOT called (low pressure)");
811
+ // 3) One-shot: a second agent_end without a new length stop must NOT re-nudge.
812
+ await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
813
+ assert.equal(h.sendUserMessages.length, 1, "one-shot: no second nudge without a new length stop");
814
+ });
815
+ test("S28: length-stop auto-continue fires even when config.auto === false (autoContinueLengthStop is the sole gate)", async () => {
816
+ // Disable auto (durable-trim + queued-resume) but keep the length-stop flag on.
817
+ // Set BEFORE harness() loads the compiled extension so loadConfig() picks it up.
818
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
819
+ process.env.MEGACOMPACT_AUTO = "false";
820
+ try {
821
+ // Re-load the extension with the new env so config.auto is false but
822
+ // autoContinueLengthStop stays true (default).
823
+ const h2 = harness();
824
+ const lowPressureCtx = h2.ctx({
825
+ isIdle: () => true,
826
+ hasPendingMessages: () => false,
827
+ getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
828
+ });
829
+ // Length stop arms the flag; agent_end must still nudge despite auto=false.
830
+ await h2.fire("turn_end", { type: "turn_end", turnIndex: 1, message: { role: "assistant", stopReason: "length" } }, lowPressureCtx);
831
+ await h2.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
832
+ assert.equal(h2.sendUserMessages.length, 1, "auto=false: length stop still nudges");
833
+ assert.match(h2.sendUserMessages[0], /output-token cap/, "auto=false: nudge references the output-token truncation");
834
+ assert.equal(h2.compactCalls.length, 0, "auto=false: ctx.compact() NOT called (auto gates durable-trim)");
835
+ }
836
+ finally {
837
+ if (prevAuto === undefined)
838
+ delete process.env.MEGACOMPACT_AUTO;
839
+ else
840
+ process.env.MEGACOMPACT_AUTO = prevAuto;
841
+ }
842
+ });
843
+ // Helper: read <stateDir>/events.log JSONL and return the list of event `type`s.
844
+ // Dashboard.event (extensions/mega-dashboard.ts) appends `{ ts, type, ...data }`
845
+ // per line. Used to assert the S28 length_stop / length_stop_continue dashboard
846
+ // events fire on the right paths (spec acceptance #7; OPEN issue #3).
847
+ function eventTypes(stateDir) {
848
+ const { readFileSync: rf, existsSync: ex } = require("node:fs");
849
+ const { join: j } = require("node:path");
850
+ const logPath = j(stateDir, "events.log");
851
+ if (!ex(logPath))
852
+ return [];
853
+ const content = rf(logPath, "utf-8").trim();
854
+ if (content.length === 0)
855
+ return [];
856
+ return content
857
+ .split("\n")
858
+ .map((line) => {
859
+ try {
860
+ return JSON.parse(line).type;
861
+ }
862
+ catch {
863
+ return undefined;
864
+ }
865
+ })
866
+ .filter((t) => typeof t === "string");
867
+ }
868
+ test("S28: length_stop + length_stop_continue dashboard events fire on the right paths", async () => {
869
+ const h = harness();
870
+ const lowPressureCtx = h.ctx({
871
+ isIdle: () => true,
872
+ hasPendingMessages: () => false,
873
+ getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
874
+ });
875
+ // Normal stop: no length_stop event, no nudge, no length_stop_continue.
876
+ await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message: { role: "assistant", stopReason: "stop" } }, lowPressureCtx);
877
+ await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
878
+ const afterNormal = eventTypes(h.stateDir);
879
+ assert.ok(!afterNormal.includes("length_stop"), "normal stop: no length_stop dashboard event");
880
+ assert.ok(!afterNormal.includes("length_stop_continue"), "normal stop: no length_stop_continue dashboard event");
881
+ assert.equal(h.sendUserMessages.length, 0, "normal stop: no nudge");
882
+ // Length stop: length_stop fires on turn_end, length_stop_continue on agent_end.
883
+ await h.fire("turn_end", { type: "turn_end", turnIndex: 2, message: { role: "assistant", stopReason: "length" } }, lowPressureCtx);
884
+ const afterTurnEnd = eventTypes(h.stateDir);
885
+ assert.ok(afterTurnEnd.includes("length_stop"), "length stop: length_stop dashboard event fired on turn_end");
886
+ await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
887
+ const afterAgentEnd = eventTypes(h.stateDir);
888
+ assert.ok(afterAgentEnd.includes("length_stop_continue"), "length stop: length_stop_continue dashboard event fired on agent_end");
889
+ assert.equal(h.sendUserMessages.length, 1, "length stop: exactly one nudge");
890
+ });
891
+ test("S28: non-length stopReasons do not arm the flag (no nudge, no length_stop event)", async () => {
892
+ const h = harness();
893
+ const lowPressureCtx = h.ctx({
894
+ isIdle: () => true,
895
+ hasPendingMessages: () => false,
896
+ getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
897
+ });
898
+ // Every other pi-ai StopReason must leave the flag unset → no nudge + no event.
899
+ for (const stopReason of ["tool_use", "error", "aborted"]) {
900
+ await h.fire("turn_end", { type: "turn_end", turnIndex: 1, message: { role: "assistant", stopReason } }, lowPressureCtx);
901
+ await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
902
+ }
903
+ assert.equal(h.sendUserMessages.length, 0, "non-length stopReasons: no nudge");
904
+ assert.ok(!eventTypes(h.stateDir).includes("length_stop"), "non-length stopReasons: no length_stop dashboard event");
905
+ });
906
+ // ---- S29: percent-based auto-compact trigger (gate on context %, not tokens) -
907
+ // The context-handler gate now fires on pct/100 >= (autoPctTrigger ?? tierPct)
908
+ // for tiered configs, with a token FALLBACK when pct is null. `custom` keeps the
909
+ // absolute token gate. These are the first tests to drive a `context` event
910
+ // on a tiered config (the default harness forces custom via THRESHOLD_TOKENS=50).
911
+ /** S29 tiered-config helper: tiered (not custom), low tier (tierPct 0.5), with
912
+ * the legacy durable-trim flag off + anchor floor lowered so the live trim
913
+ * returns a trimmed view (mirrors the S16 live-trim test setup at ~line 329). */
914
+ function s29TieredCtx(h, usage) {
915
+ delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
916
+ delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
917
+ process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES = "1";
918
+ return h.ctx({
919
+ isIdle: () => true,
920
+ hasPendingMessages: () => false,
921
+ getContextUsage: () => usage,
922
+ });
923
+ }
924
+ test("S29: percent gate fires when tokens under-report (tiered low, percent 55, tokens 10)", async () => {
925
+ process.env.MEGACOMPACT_TIER = "low";
926
+ delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
927
+ delete process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
928
+ try {
929
+ const h = harness({ keepTier: true, keepThreshold: true });
930
+ // tokens=10 (under the 0.5×10000=5000 token gate), percent=55 (>= 0.5).
931
+ // The OLD token-only gate would return (10 < 5000) → no trim. The S29
932
+ // percent gate (0.55 >= 0.5) fires → live trim returns a trimmed view.
933
+ const ctx = s29TieredCtx(h, { tokens: 10, contextWindow: 10000, percent: 55 });
934
+ const res = await h.fire("context", { type: "context", messages: h.session }, ctx);
935
+ assert.ok(res && typeof res === "object", "percent gate: live trim returned a result object");
936
+ assert.ok(Array.isArray(res.messages), "percent gate: result has a trimmed messages array");
937
+ assert.ok(res.messages.length < h.session.length, "percent gate: trimmed view is shorter than the full session");
938
+ assert.equal(h.compactCalls.length, 0, "percent gate: live trim, no ctx.compact()");
939
+ // Control: percent 40 (< 0.5) → no trim, even with the same under-reported tokens.
940
+ const h2 = harness({ keepTier: true, keepThreshold: true });
941
+ const ctx2 = s29TieredCtx(h2, { tokens: 10, contextWindow: 10000, percent: 40 });
942
+ const res2 = await h2.fire("context", { type: "context", messages: h2.session }, ctx2);
943
+ assert.ok(!(res2 && typeof res2 === "object" && Array.isArray(res2.messages)), "percent below fire point: no trim (token count 10 is also below the token gate)");
944
+ }
945
+ finally {
946
+ delete process.env.MEGACOMPACT_TIER;
947
+ delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
948
+ }
949
+ });
950
+ test("S29: MEGACOMPACT_AUTO_PCT_TRIGGER overrides the tier fire point (0.85)", async () => {
951
+ process.env.MEGACOMPACT_TIER = "low"; // tierPct 0.5
952
+ process.env.MEGACOMPACT_AUTO_PCT_TRIGGER = "0.85";
953
+ delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
954
+ try {
955
+ // percent 80 < 0.85 → no trim.
956
+ const h = harness({ keepTier: true, keepThreshold: true });
957
+ const ctx80 = s29TieredCtx(h, { tokens: 10, contextWindow: 10000, percent: 80 });
958
+ const res80 = await h.fire("context", { type: "context", messages: h.session }, ctx80);
959
+ assert.ok(!(res80 && typeof res80 === "object" && Array.isArray(res80.messages)), "override 0.85: percent 80 does NOT trim (below the override fire point)");
960
+ // percent 90 >= 0.85 → trim fires (despite the tier's own 0.5 fire point).
961
+ const h2 = harness({ keepTier: true, keepThreshold: true });
962
+ const ctx90 = s29TieredCtx(h2, { tokens: 10, contextWindow: 10000, percent: 90 });
963
+ const res90 = await h2.fire("context", { type: "context", messages: h2.session }, ctx90);
964
+ assert.ok(res90 && Array.isArray(res90.messages) && res90.messages.length < h2.session.length, "override 0.85: percent 90 DOES trim (above the override fire point)");
965
+ }
966
+ finally {
967
+ delete process.env.MEGACOMPACT_TIER;
968
+ delete process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
969
+ delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
970
+ }
971
+ });
972
+ test("S29: custom tier keeps the absolute token gate (percent 40 but tokens 100 >= 50)", async () => {
973
+ // MEGACOMPACT_THRESHOLD_TOKENS → custom (tierPct null) → token gate, percent ignored.
974
+ process.env.MEGACOMPACT_THRESHOLD_TOKENS = "50";
975
+ delete process.env.MEGACOMPACT_TIER;
976
+ delete process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
977
+ try {
978
+ const h = harness({ keepTier: true, keepThreshold: true });
979
+ // percent 40 (low) BUT tokens 100 >= 50 threshold → custom token gate fires.
980
+ const ctx = s29TieredCtx(h, { tokens: 100, contextWindow: 10000, percent: 40 });
981
+ const res = await h.fire("context", { type: "context", messages: h.session }, ctx);
982
+ assert.ok(res && Array.isArray(res.messages) && res.messages.length < h.session.length, "custom tier: token gate fires (tokens 100 >= 50) despite low percent 40");
983
+ }
984
+ finally {
985
+ delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
986
+ delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
987
+ }
988
+ });
989
+ test("S29: tiered config with pct==null falls back to the token gate (not skipped)", async () => {
990
+ // The regression guard for the audit finding: a percent-ONLY gate would skip
991
+ // compaction when percent is unreported. S29 falls back to the token gate
992
+ // (S27 boot-fallback guarantee). tiered low: effectiveThreshold = 0.5×10000 = 5000;
993
+ // tokens 6000 >= 5000 → token fallback fires.
994
+ process.env.MEGACOMPACT_TIER = "low";
995
+ delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
996
+ delete process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
997
+ try {
998
+ const h = harness({ keepTier: true, keepThreshold: true });
999
+ const ctx = s29TieredCtx(h, { tokens: 6000, contextWindow: 10000, percent: null });
1000
+ const res = await h.fire("context", { type: "context", messages: h.session }, ctx);
1001
+ assert.ok(res && Array.isArray(res.messages) && res.messages.length < h.session.length, "pct==null on tiered: token fallback fires (NOT skipped) — S27 boot-fallback preserved");
1002
+ }
1003
+ finally {
1004
+ delete process.env.MEGACOMPACT_TIER;
1005
+ delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
1006
+ }
1007
+ });
809
1008
  test("cleanup", async () => {
810
1009
  // Terminate the global PGlite cross-repo index (WASM worker thread) so the
811
1010
  // test process can exit. Without this, node --test never returns even though
@@ -118,6 +118,14 @@ export { pressureFromPct, preserveRecentForPressure, pressureRatio, pressureBand
118
118
  /** Build the resolved config from env + defaults. */
119
119
  export function loadConfig() {
120
120
  const { tier, tierPct, thresholdTokens } = resolveThreshold();
121
+ // S29: optional percent-based fire-point override for tiered configs.
122
+ // null = inherit tierPct (default; preserves existing fire points). Clamped
123
+ // to [0.1, 1] so a bogus env can't disable or invert the gate. Ignored by
124
+ // the `custom` tier (tierPct null) which keeps the absolute token gate.
125
+ const aptRaw = process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
126
+ const autoPctTrigger = aptRaw && aptRaw !== "" && Number.isFinite(Number(aptRaw))
127
+ ? Math.min(1, Math.max(0.1, Number(aptRaw)))
128
+ : null;
121
129
  return {
122
130
  tier,
123
131
  tierPct,
@@ -131,6 +139,8 @@ export function loadConfig() {
131
139
  preserveRecentMin: envFlag("MEGACOMPACT_PRESERVE_RECENT_MIN", 2),
132
140
  auto: envBool("MEGACOMPACT_AUTO", true),
133
141
  autoInline: envBool("MEGACOMPACT_AUTO_INLINE", true),
142
+ autoContinueLengthStop: envBool("MEGACOMPACT_AUTO_CONTINUE_LENGTH_STOP", true),
143
+ autoPctTrigger,
134
144
  autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
135
145
  dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
136
146
  raptorEnabled: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
@@ -16,7 +16,7 @@ import { runCompact, doRecall, doRecallAsync, piCompactWouldNoop, runMemoryRevie
16
16
  import { recallMemoriesAndInline } from "../src/recall.js";
17
17
  import { driveNativeCompaction, } from "./mega-compact-driver.js";
18
18
  import { computeLiveTrimCut, liveTrimSummaryMessage } from "./mega-trim.js";
19
- import { pressureFromPct, memoryReviewCadence, } from "./mega-config.js";
19
+ import { pressureFromPct, pressureRatio, memoryReviewCadence, } from "./mega-config.js";
20
20
  import { createHash } from "node:crypto";
21
21
  /**
22
22
  * Convert a pi AgentMessage to a RawTranscriptRow for the DB mirror.
@@ -217,7 +217,7 @@ export function registerEventHandlers(pi, runtime, config) {
217
217
  // compaction AND there is queued work AND we haven't nudged recently, nudge
218
218
  // once so the agent continues (the live trim should make this rare). Guarded
219
219
  // to never busy-loop: one nudge per 30s, only when truly idle + queued.
220
- if (config.auto && runtime.activeAgents === 0) {
220
+ if ((config.auto || config.autoContinueLengthStop) && runtime.activeAgents === 0) {
221
221
  try {
222
222
  const idle = ctx.isIdle?.() ?? true;
223
223
  const queued = ctx.hasPendingMessages?.() ?? false;
@@ -264,7 +264,7 @@ export function registerEventHandlers(pi, runtime, config) {
264
264
  // most. Instead we DECOUPLE the nudge from `queued`: after a durable
265
265
  // trim we ALWAYS nudge so the agent reliably restarts. Debounced 30s.
266
266
  let didDurableTrim = false;
267
- if (idle && overThreshold && now >= runtime.debounceUntil) {
267
+ if (config.auto && idle && overThreshold && now >= runtime.debounceUntil) {
268
268
  // COMPACT-DEDUP FIX: skip the manual durable-trim trigger when pi's
269
269
  // NATIVE auto-compaction just fired (or is in-flight). pi emits
270
270
  // agent_end BEFORE its own _checkCompaction (per its docstring:
@@ -295,11 +295,26 @@ export function registerEventHandlers(pi, runtime, config) {
295
295
  // Restart the agent after a mid-run durable trim (which stopped it), or
296
296
  // when it settled idle with queued work. Decoupled from `queued` for the
297
297
  // durable-trim case — see FIX note above. Debounced 30s; never blocks.
298
+ const lengthStop = config.autoContinueLengthStop && runtime.rt.lengthStopPending;
298
299
  if (idle &&
299
300
  now >= runtime.resumeNudgeUntil &&
300
- (didDurableTrim || queued)) {
301
+ ((config.auto && (didDurableTrim || queued)) || lengthStop)) {
301
302
  runtime.resumeNudgeUntil = now + 30_000;
302
- pi.sendUserMessage("[mega-compact] continue from the compacted context above.");
303
+ if (runtime.rt.lengthStopPending) {
304
+ runtime.rt.lengthStopPending = false; // one-shot: never re-fire for same stop
305
+ runtime.dashboard.event("length_stop_continue", { turnIndex: runtime.currentTurn });
306
+ runtime.logger.info("length_stop_continue", {
307
+ sessionId: runtime.rt.sessionId,
308
+ didDurableTrim,
309
+ queued,
310
+ });
311
+ }
312
+ // S28: when a length-stop (max-output-token truncation) fired WITHOUT a durable trim, do NOT claim a compaction happened
313
+ // (nothing was compacted on the low-pressure length path). Branch the message so the nudge matches reality.
314
+ const nudgeMsg = lengthStop && !didDurableTrim
315
+ ? "[mega-compact] the last response hit the output-token cap; continue from where it stopped."
316
+ : "[mega-compact] continue from the compacted context above.";
317
+ pi.sendUserMessage(nudgeMsg);
303
318
  }
304
319
  }
305
320
  catch {
@@ -310,6 +325,7 @@ export function registerEventHandlers(pi, runtime, config) {
310
325
  });
311
326
  pi.on("turn_start", async (event, ctx) => {
312
327
  runtime.currentTurn = event.turnIndex;
328
+ runtime.rt.lengthStopPending = false; // S28: re-arm defensively each user turn
313
329
  runtime.dashboard.event("turn_start", { turnIndex: event.turnIndex });
314
330
  runtime.snapshot(ctx);
315
331
  });
@@ -333,6 +349,15 @@ export function registerEventHandlers(pi, runtime, config) {
333
349
  await runMemoryReview(runtime, view, "turn");
334
350
  }
335
351
  }
352
+ // S28: detect max-output-token truncation. event.message.stopReason is the
353
+ // pi-ai StopReason union; 'length' == generation hit max_tokens OUTPUT cap
354
+ // (INPUT-orthogonal to context-window overflow). Arm the agent_end nudge.
355
+ if (config.autoContinueLengthStop &&
356
+ event.message.role === "assistant" &&
357
+ event.message.stopReason === "length") {
358
+ runtime.rt.lengthStopPending = true;
359
+ runtime.dashboard.event("length_stop", { turnIndex: event.turnIndex });
360
+ }
336
361
  });
337
362
  // ---- Auto-trigger: live trim (compact and continue) + native durable ----
338
363
  // S16 redesign: we NO LONGER call ctx.compact() from the auto-trigger by
@@ -358,13 +383,11 @@ export function registerEventHandlers(pi, runtime, config) {
358
383
  runtime.lastCtxPercent = pct ?? null;
359
384
  runtime.lastCtxWindow = usage?.contextWindow ?? 0;
360
385
  runtime.snapshot(ctx);
361
- if (pct == null)
362
- return;
363
386
  const messages = event.messages;
364
387
  const view = runtime.engineView(messages);
365
388
  const currentTokens = usage?.tokens ??
366
389
  estimateSessionTokens(view) ??
367
- Math.round((pct / 100) * (usage?.contextWindow ?? 0));
390
+ Math.round(((pct ?? 0) / 100) * (usage?.contextWindow ?? 0));
368
391
  // S27 DB-mirror: append ALL incoming messages to raw_transcript.
369
392
  // Runs BEFORE fast-gate so every message is captured, even if we
370
393
  // don't compact this turn. Append is idempotent (content_hash PK).
@@ -382,14 +405,36 @@ export function registerEventHandlers(pi, runtime, config) {
382
405
  runtime.logger.warn("db-mirror-append-fail", { error: String(e) });
383
406
  }
384
407
  }
385
- // FAST GATE: token-based (tier% of the window), not a static amount.
386
- if (currentTokens < runtime.effectiveThreshold) {
387
- runtime.diagCtxFastGate++;
388
- return;
408
+ // S29 FAST GATE: drive the auto-trigger off the context % (the number the
409
+ // menu bar shows), NOT the token count — the model under-reports tokens,
410
+ // so a token-only gate misses the overshoot that causes max-output-token
411
+ // truncation. The fire point is the tier's percent threshold (tierPct)
412
+ // unless overridden by MEGACOMPACT_AUTO_PCT_TRIGGER. `custom` (absolute
413
+ // MEGACOMPACT_THRESHOLD_TOKENS, tierPct null) is an explicit opt-out of
414
+ // percent scaling — it keeps the token gate. When pct is unavailable
415
+ // (window unknown / a model that doesn't report percent) a tiered config
416
+ // falls back to the token gate (S27 boot-fallback guarantee) instead of
417
+ // skipping compaction — a percent-only gate would regress that.
418
+ let gatePassed = false;
419
+ if (config.tierPct != null && pct != null) {
420
+ const firePct = config.autoPctTrigger ?? config.tierPct;
421
+ gatePassed = pct / 100 >= firePct;
389
422
  }
390
- const check = autoCompactCheck(currentTokens, runtime.effectiveThreshold); // SERVER-STYLE CONFIRM (local)
391
- if (!check.shouldCompact) {
392
- runtime.diagCtxNoCompact++;
423
+ else {
424
+ // custom tier OR tiered-but-pct-unavailable → token gate (S27 fallback).
425
+ if (currentTokens < runtime.effectiveThreshold) {
426
+ runtime.diagCtxFastGate++;
427
+ return;
428
+ }
429
+ const check = autoCompactCheck(currentTokens, runtime.effectiveThreshold); // SERVER-STYLE CONFIRM (local)
430
+ if (!check.shouldCompact) {
431
+ runtime.diagCtxNoCompact++;
432
+ return;
433
+ }
434
+ gatePassed = true;
435
+ }
436
+ if (!gatePassed) {
437
+ runtime.diagCtxFastGate++;
393
438
  return;
394
439
  }
395
440
  // Debounce so we don't fire on every context event past threshold.
@@ -400,8 +445,10 @@ export function registerEventHandlers(pi, runtime, config) {
400
445
  }
401
446
  runtime.debounceUntil = now + 2000;
402
447
  // Adaptive compression (Fix E): scale compression strength + keepFrom depth
403
- // with how close we are to the model context limit.
404
- const pressure = pressureFromPct(pct);
448
+ // with how close we are to the model context limit. Null-safe: when the
449
+ // token-fallback path ran (pct unavailable) use the token-basis pressure
450
+ // (the same basis the runtime `pressure` getter uses for custom/no-window).
451
+ const pressure = pct != null ? pressureFromPct(pct) : pressureRatio(currentTokens, runtime.effectiveThreshold);
405
452
  const ran = runCompact(pi, runtime, config, ctx, messages, {
406
453
  compressionPressure: pressure,
407
454
  });
@@ -11,7 +11,7 @@ import { compactSession } from "../src/engine.js";
11
11
  import { recallAndInline, recallAndInlineAsync, formatRecallBlock } from "../src/recall.js";
12
12
  import { normalizeSessionId } from "../src/store.js";
13
13
  import { estimateBlockTokens } from "../src/tokens.js";
14
- import { touchSession, logDaily } from "../src/store/sqlite.js";
14
+ import { touchSession, logDaily, incCompactCount, incRecallInjected, incCacheHitTokens } from "../src/store/sqlite.js";
15
15
  import { consolidateMemories } from "../src/memory.js";
16
16
  import { C, MARKER_TYPE, } from "./mega-runtime.js";
17
17
  import { resolveRepoRoot, preserveRecentForPressure } from "./mega-config.js";
@@ -104,6 +104,12 @@ function doCompact(view, keepFrom, opts, sid, config, pi, ctx, runtime) {
104
104
  ? result.originalTokenEstimate
105
105
  : Math.max(0, result.originalTokenEstimate - result.tokenEstimate);
106
106
  runtime.rt.tokensSaved += saved;
107
+ runtime.rt.compactCount += 1;
108
+ incCompactCount(runtime.currentStateDir);
109
+ if (result.deduped) {
110
+ runtime.rt.cacheHitTokens += saved;
111
+ incCacheHitTokens(saved, runtime.currentStateDir);
112
+ }
107
113
  runtime.rt.lastCompactAt = Date.now();
108
114
  if (result.deduped)
109
115
  runtime.rt.dedupSkips++;
@@ -376,6 +382,15 @@ export function doRecall(runtime, config, ctx, query, source) {
376
382
  runtime.pushTicker(`${C.amber}↩${C.reset} recalled ${top.checkpoint.checkpointId} · ${scorePct}% · ${label}`);
377
383
  runtime.lastWhy = `why: recalled@${scorePct}% (${result.toInject.length} chkpt)`;
378
384
  }
385
+ if (result.toInject.length > 0) {
386
+ let sumTokens = 0;
387
+ for (const h of result.toInject)
388
+ sumTokens += h.checkpoint.tokenEstimate;
389
+ runtime.rt.recallInjections += result.toInject.length;
390
+ runtime.rt.cacheHitTokens += sumTokens;
391
+ incRecallInjected(result.toInject.length, runtime.currentStateDir);
392
+ incCacheHitTokens(sumTokens, runtime.currentStateDir);
393
+ }
379
394
  return result;
380
395
  }
381
396
  /**
@@ -425,6 +440,15 @@ export async function doRecallAsync(runtime, config, ctx, query, source, opts =
425
440
  }
426
441
  }
427
442
  const block = merged.length ? formatRecallBlock(merged) : "";
443
+ if (merged.length > 0) {
444
+ let sumTokens = 0;
445
+ for (const h of merged)
446
+ sumTokens += h.checkpoint.tokenEstimate;
447
+ runtime.rt.recallInjections += merged.length;
448
+ runtime.rt.cacheHitTokens += sumTokens;
449
+ incRecallInjected(merged.length, runtime.currentStateDir);
450
+ incCacheHitTokens(sumTokens, runtime.currentStateDir);
451
+ }
428
452
  return {
429
453
  toInject: merged,
430
454
  report: merged.map((h) => ` • ${h.checkpoint.checkpointId}${h.repoId ? ` (from ${h.repoId.split("/").filter(Boolean).pop()})` : ""}`),
@@ -16,7 +16,7 @@ import { VectorStore } from "../src/vectorStore.js";
16
16
  import { toEngineMessages } from "../src/adapt.js";
17
17
  import { normalizeSessionId } from "../src/store.js";
18
18
  import { Logger } from "../src/log.js";
19
- import { recordModelSnapshot, latestModelSnapshot, upsertRepoRegistry, recordRepoModel, } from "../src/store/sqlite.js";
19
+ import { recordModelSnapshot, latestModelSnapshot, upsertRepoRegistry, recordRepoModel, getDedupStats, getCompactCount, getRecallInjected, getCacheHitTokensSaved, } from "../src/store/sqlite.js";
20
20
  import { detectCrossRepoDrift } from "../src/driftDetection.js";
21
21
  import { repoStateDir, resolveRepoRoot, pressureRatio, pressureFromPct, pressureBand, effectiveThresholdTokens, } from "./mega-config.js";
22
22
  import { Dashboard } from "./mega-dashboard.js";
@@ -57,6 +57,10 @@ export const C = {
57
57
  red: "\x1b[38;5;203m", // pressure / overflow
58
58
  };
59
59
  const PULSE = ["◐", "◓", "◑", "◒"];
60
+ // Rough tokens-processed-per-second heuristic for the dashboard's "time saved"
61
+ // estimate. Throughput varies by model/hardware; this is order-of-magnitude so
62
+ // the dashboard can show a human-readable figure, not a precise measurement.
63
+ const TOKENS_PER_SEC_ESTIMATE = 2000;
60
64
  // ── Full-width widget panel helpers ────────────────────────────────────────
61
65
  // pi's above-editor widget renderer (a Container of Text lines) does NOT pass
62
66
  // a terminal width to setWidget(), so lines render left-aligned by default. To
@@ -192,6 +196,10 @@ export class MegaRuntime {
192
196
  tokensSaved: 0,
193
197
  lastCompactAt: null,
194
198
  lastNativeCompactAt: null,
199
+ compactCount: 0,
200
+ recallInjections: 0,
201
+ cacheHitTokens: 0,
202
+ lengthStopPending: false,
195
203
  };
196
204
  debounceUntil = 0;
197
205
  // S16: debounce for the agent_end resume nudge (avoid busy-loops).
@@ -390,6 +398,12 @@ export class MegaRuntime {
390
398
  const st = this.store.stats(this.rt.sessionId);
391
399
  const repo = this.store.repoStats();
392
400
  const di = this.store.dataInvariant();
401
+ // Live + store-wide cache-hit / compaction counters for the dashboard.
402
+ const ds = getDedupStats(this.currentStateDir);
403
+ const cacheHitsTotal = ds.deduped + getRecallInjected(this.currentStateDir);
404
+ const cacheHitsTotalTokens = getCacheHitTokensSaved(this.currentStateDir);
405
+ const cacheHitsSession = this.rt.dedupSkips + this.rt.recallInjections;
406
+ const sec = (tok) => (tok || 0) / TOKENS_PER_SEC_ESTIMATE;
393
407
  // Active model/provider for the current-repo card + the multi-repo table.
394
408
  const modelSnap = latestModelSnapshot(this.currentStateDir);
395
409
  const model = modelSnap
@@ -402,15 +416,26 @@ export class MegaRuntime {
402
416
  }
403
417
  : undefined;
404
418
  // effectiveThresholdPct: the live fire point as a % of the window (null for
405
- // `custom`, which has no tierPct). Used by armed/ready + the dashboard.
406
- const effectiveThresholdPct = this.config.tierPct != null ? this.config.tierPct * 100 : null;
419
+ // `custom`, which has no tierPct). S29: honors MEGACOMPACT_AUTO_PCT_TRIGGER
420
+ // override so the dashboard's armed/ready match the context-handler gate
421
+ // (which fires on this same %). Used by armed/ready + the dashboard.
422
+ const effectiveThresholdPct = this.config.tierPct != null
423
+ ? (this.config.autoPctTrigger ?? this.config.tierPct) * 100
424
+ : null;
407
425
  // armed lights at/above the REAL fire point: max(effectiveThresholdPct,
408
426
  // fastGatePct). fastGatePct already equals tierPct*100 by default, but a
409
427
  // MEGACOMPACT_FAST_GATE_PCT override can raise it, so we take the max.
410
428
  const armed = this.lastCtxPercent != null &&
411
429
  this.lastCtxPercent >=
412
430
  Math.max(effectiveThresholdPct ?? 0, this.config.fastGatePct);
413
- const ready = armed && (this.lastCtxTokens ?? 0) >= this.effectiveThreshold;
431
+ // S29: ready mirrors the context-handler gate's basis percent for tiered
432
+ // (the gate fires on pct), tokens for custom (the gate fires on tokens).
433
+ // Previously this always required tokens, so the dashboard could show
434
+ // "armed" (percent high) but never "ready" when tokens were under-reported
435
+ // — the same inconsistency the S29 gate fix removes.
436
+ const ready = this.config.tierPct != null
437
+ ? armed && (this.lastCtxPercent ?? 0) >= (effectiveThresholdPct ?? 0)
438
+ : armed && (this.lastCtxTokens ?? 0) >= this.effectiveThreshold;
414
439
  this.dashboard.snapshot({
415
440
  version: 1,
416
441
  updatedAt: new Date().toISOString(),
@@ -505,6 +530,20 @@ export class MegaRuntime {
505
530
  duplicatesCollapsed: di.duplicatesCollapsed,
506
531
  bytesPermanentlyDeleted: di.bytesPermanentlyDeleted,
507
532
  },
533
+ cacheHits: {
534
+ session: cacheHitsSession,
535
+ total: cacheHitsTotal,
536
+ sessionTokensSaved: this.rt.cacheHitTokens,
537
+ totalTokensSaved: cacheHitsTotalTokens,
538
+ },
539
+ compacts: {
540
+ session: this.rt.compactCount,
541
+ total: getCompactCount(this.currentStateDir),
542
+ },
543
+ timeSaved: {
544
+ compact: { sessionSec: sec(this.rt.tokensSaved), totalSec: sec(this.store.repoStats().tokensSaved) },
545
+ cacheHit: { sessionSec: sec(this.rt.cacheHitTokens), totalSec: sec(cacheHitsTotalTokens) },
546
+ },
508
547
  model,
509
548
  });
510
549
  // Live stats widget above the editor
@@ -517,7 +556,9 @@ export class MegaRuntime {
517
556
  ? `${Math.round(this.lastCtxWindow / 1000)}k`
518
557
  : "?";
519
558
  const pctStr = this.lastCtxPercent != null
520
- ? `${Math.round(this.lastCtxPercent * 10) / 10}%`
559
+ ? this.lastCtxPercent > 100
560
+ ? `>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.
561
+ : `${Math.round(this.lastCtxPercent * 10) / 10}%`
521
562
  : "?%";
522
563
  // S24: the tier label is the LIVE pressure band (low/medium/high/ultra/
523
564
  // mega), not the static env preset. It climbs as context fills.
@@ -722,6 +763,10 @@ export class MegaRuntime {
722
763
  tokensSaved: 0,
723
764
  lastCompactAt: null,
724
765
  lastNativeCompactAt: null,
766
+ compactCount: 0,
767
+ recallInjections: 0,
768
+ cacheHitTokens: 0,
769
+ lengthStopPending: false,
725
770
  };
726
771
  this.statusKey = undefined;
727
772
  this.activeAgents = 0;