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.
@@ -50,6 +50,7 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
50
50
  let statusText: string | undefined;
51
51
  const notifies: string[] = [];
52
52
  const compactCalls: any[] = [];
53
+ const sendUserMessages: string[] = [];
53
54
 
54
55
  // Minimal AgentMessage factory for the session we project into the extension.
55
56
  function msg(role: string, text: string, toolName?: string): AgentMessage {
@@ -192,7 +193,7 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
192
193
  registerMessageRenderer: () => {},
193
194
  registerEntryRenderer: () => {},
194
195
  sendMessage: (_m: any) => {},
195
- sendUserMessage: () => {},
196
+ sendUserMessage: (m: string) => { sendUserMessages.push(m); },
196
197
  appendEntry: (t: string, d: any) => appended.push({ t, d }),
197
198
  setSessionName: () => {},
198
199
  getSessionName: () => undefined,
@@ -221,6 +222,7 @@ function harness(opts: { keepTier?: boolean; keepThreshold?: boolean } = {}) {
221
222
  },
222
223
  notifies,
223
224
  compactCalls,
225
+ sendUserMessages,
224
226
  fire: (ev: string, event: any, ctx: any) => handlers[ev](event, ctx),
225
227
  ctx: makeCtx,
226
228
  session,
@@ -957,40 +959,6 @@ test("/dashboard skips server spawn when already running", async () => {
957
959
  delete process.env.MEGACOMPACT_DASHBOARD_PORT;
958
960
  });
959
961
 
960
- test("/dashboard-status reports running after dashboard start", async () => {
961
- // Private dashboard port base for this harness — never collides with the
962
- // parallel dashboard-server.test.js (9320 family) or a leftover server.
963
- process.env.MEGACOMPACT_DASHBOARD_PORT = "39320";
964
- const h = harness();
965
- const livPort = 39320;
966
- const { createServer } = await import("node:http");
967
- const { join: j } = await import("node:path");
968
- const { writeFileSync: wf } = await import("node:fs");
969
- const server = createServer((_req, res) => {
970
- res.writeHead(200, { "Content-Type": "application/json" });
971
- res.end(
972
- JSON.stringify({ updatedAt: new Date().toISOString(), tier: "test" }),
973
- );
974
- });
975
- await new Promise<void>((r) => server.listen(livPort, "127.0.0.1", r));
976
- wf(
977
- j(h.stateDir, "port.pid"),
978
- JSON.stringify({ port: livPort, pid: process.pid }),
979
- );
980
-
981
- const ctx = h.ctx();
982
- await h.commands["mega-dashboard-status"].handler("", ctx);
983
- assert.ok(
984
- h.notifies.some(
985
- (n) => n.includes("running") && n.includes(String(livPort)),
986
- ),
987
- "reports running with port",
988
- );
989
-
990
- await new Promise<void>((r) => server.close(() => r()));
991
- delete process.env.MEGACOMPACT_DASHBOARD_PORT;
992
- });
993
-
994
962
  test("state snapshot writes dashboard.json after compaction", async () => {
995
963
  const h = harness();
996
964
  const ctx = h.ctx({
@@ -1056,6 +1024,299 @@ test("events.log receives compaction events", async () => {
1056
1024
  }
1057
1025
  });
1058
1026
 
1027
+ test("S28: length-stop auto-continue nudges once, no ctx.compact on low-pressure length path", async () => {
1028
+ const h = harness();
1029
+ // Force a low-pressure context so the durable-trim branch (which calls
1030
+ // ctx.compact()) is NOT taken; only the length-stop nudge should fire.
1031
+ const lowPressureCtx = h.ctx({
1032
+ isIdle: () => true,
1033
+ hasPendingMessages: () => false,
1034
+ getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
1035
+ });
1036
+ // 1) Normal stop: no length flag armed → no nudge.
1037
+ await h.fire(
1038
+ "turn_end",
1039
+ { type: "turn_end", turnIndex: 1, message: { role: "assistant", stopReason: "stop" } },
1040
+ lowPressureCtx,
1041
+ );
1042
+ await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
1043
+ assert.equal(h.sendUserMessages.length, 0, "normal stop: no nudge");
1044
+ assert.equal(h.compactCalls.length, 0, "normal stop: no ctx.compact");
1045
+
1046
+ // 2) Length stop: arms the flag, agent_end fires exactly one continue nudge
1047
+ // that references the output-token truncation (not a compaction).
1048
+ await h.fire(
1049
+ "turn_end",
1050
+ { type: "turn_end", turnIndex: 2, message: { role: "assistant", stopReason: "length" } },
1051
+ lowPressureCtx,
1052
+ );
1053
+ await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
1054
+ assert.equal(h.sendUserMessages.length, 1, "length stop: exactly one nudge");
1055
+ assert.match(
1056
+ h.sendUserMessages[0],
1057
+ /output-token cap/,
1058
+ "length stop: nudge references the output-token truncation",
1059
+ );
1060
+ assert.equal(h.compactCalls.length, 0, "length path: ctx.compact() NOT called (low pressure)");
1061
+
1062
+ // 3) One-shot: a second agent_end without a new length stop must NOT re-nudge.
1063
+ await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
1064
+ assert.equal(h.sendUserMessages.length, 1, "one-shot: no second nudge without a new length stop");
1065
+ });
1066
+
1067
+ test("S28: length-stop auto-continue fires even when config.auto === false (autoContinueLengthStop is the sole gate)", async () => {
1068
+ // Disable auto (durable-trim + queued-resume) but keep the length-stop flag on.
1069
+ // Set BEFORE harness() loads the compiled extension so loadConfig() picks it up.
1070
+ const prevAuto = process.env.MEGACOMPACT_AUTO;
1071
+ process.env.MEGACOMPACT_AUTO = "false";
1072
+ try {
1073
+ // Re-load the extension with the new env so config.auto is false but
1074
+ // autoContinueLengthStop stays true (default).
1075
+ const h2 = harness();
1076
+ const lowPressureCtx = h2.ctx({
1077
+ isIdle: () => true,
1078
+ hasPendingMessages: () => false,
1079
+ getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
1080
+ });
1081
+ // Length stop arms the flag; agent_end must still nudge despite auto=false.
1082
+ await h2.fire(
1083
+ "turn_end",
1084
+ { type: "turn_end", turnIndex: 1, message: { role: "assistant", stopReason: "length" } },
1085
+ lowPressureCtx,
1086
+ );
1087
+ await h2.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
1088
+ assert.equal(h2.sendUserMessages.length, 1, "auto=false: length stop still nudges");
1089
+ assert.match(
1090
+ h2.sendUserMessages[0],
1091
+ /output-token cap/,
1092
+ "auto=false: nudge references the output-token truncation",
1093
+ );
1094
+ assert.equal(h2.compactCalls.length, 0, "auto=false: ctx.compact() NOT called (auto gates durable-trim)");
1095
+ } finally {
1096
+ if (prevAuto === undefined) delete process.env.MEGACOMPACT_AUTO;
1097
+ else process.env.MEGACOMPACT_AUTO = prevAuto;
1098
+ }
1099
+ });
1100
+
1101
+ // Helper: read <stateDir>/events.log JSONL and return the list of event `type`s.
1102
+ // Dashboard.event (extensions/mega-dashboard.ts) appends `{ ts, type, ...data }`
1103
+ // per line. Used to assert the S28 length_stop / length_stop_continue dashboard
1104
+ // events fire on the right paths (spec acceptance #7; OPEN issue #3).
1105
+ function eventTypes(stateDir: string): string[] {
1106
+ const { readFileSync: rf, existsSync: ex } = require("node:fs") as typeof import("node:fs");
1107
+ const { join: j } = require("node:path") as typeof import("node:path");
1108
+ const logPath = j(stateDir, "events.log");
1109
+ if (!ex(logPath)) return [];
1110
+ const content = rf(logPath, "utf-8").trim();
1111
+ if (content.length === 0) return [];
1112
+ return content
1113
+ .split("\n")
1114
+ .map((line) => {
1115
+ try {
1116
+ return JSON.parse(line).type;
1117
+ } catch {
1118
+ return undefined;
1119
+ }
1120
+ })
1121
+ .filter((t): t is string => typeof t === "string");
1122
+ }
1123
+
1124
+ test("S28: length_stop + length_stop_continue dashboard events fire on the right paths", async () => {
1125
+ const h = harness();
1126
+ const lowPressureCtx = h.ctx({
1127
+ isIdle: () => true,
1128
+ hasPendingMessages: () => false,
1129
+ getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
1130
+ });
1131
+ // Normal stop: no length_stop event, no nudge, no length_stop_continue.
1132
+ await h.fire(
1133
+ "turn_end",
1134
+ { type: "turn_end", turnIndex: 1, message: { role: "assistant", stopReason: "stop" } },
1135
+ lowPressureCtx,
1136
+ );
1137
+ await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
1138
+ const afterNormal = eventTypes(h.stateDir);
1139
+ assert.ok(
1140
+ !afterNormal.includes("length_stop"),
1141
+ "normal stop: no length_stop dashboard event",
1142
+ );
1143
+ assert.ok(
1144
+ !afterNormal.includes("length_stop_continue"),
1145
+ "normal stop: no length_stop_continue dashboard event",
1146
+ );
1147
+ assert.equal(h.sendUserMessages.length, 0, "normal stop: no nudge");
1148
+
1149
+ // Length stop: length_stop fires on turn_end, length_stop_continue on agent_end.
1150
+ await h.fire(
1151
+ "turn_end",
1152
+ { type: "turn_end", turnIndex: 2, message: { role: "assistant", stopReason: "length" } },
1153
+ lowPressureCtx,
1154
+ );
1155
+ const afterTurnEnd = eventTypes(h.stateDir);
1156
+ assert.ok(
1157
+ afterTurnEnd.includes("length_stop"),
1158
+ "length stop: length_stop dashboard event fired on turn_end",
1159
+ );
1160
+ await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
1161
+ const afterAgentEnd = eventTypes(h.stateDir);
1162
+ assert.ok(
1163
+ afterAgentEnd.includes("length_stop_continue"),
1164
+ "length stop: length_stop_continue dashboard event fired on agent_end",
1165
+ );
1166
+ assert.equal(h.sendUserMessages.length, 1, "length stop: exactly one nudge");
1167
+ });
1168
+
1169
+ test("S28: non-length stopReasons do not arm the flag (no nudge, no length_stop event)", async () => {
1170
+ const h = harness();
1171
+ const lowPressureCtx = h.ctx({
1172
+ isIdle: () => true,
1173
+ hasPendingMessages: () => false,
1174
+ getContextUsage: () => ({ tokens: 100, contextWindow: 200000, percent: 0 }),
1175
+ });
1176
+ // Every other pi-ai StopReason must leave the flag unset → no nudge + no event.
1177
+ for (const stopReason of ["tool_use", "error", "aborted"] as const) {
1178
+ await h.fire(
1179
+ "turn_end",
1180
+ { type: "turn_end", turnIndex: 1, message: { role: "assistant", stopReason } },
1181
+ lowPressureCtx,
1182
+ );
1183
+ await h.fire("agent_end", { type: "agent_end", messages: [] }, lowPressureCtx);
1184
+ }
1185
+ assert.equal(
1186
+ h.sendUserMessages.length,
1187
+ 0,
1188
+ "non-length stopReasons: no nudge",
1189
+ );
1190
+ assert.ok(
1191
+ !eventTypes(h.stateDir).includes("length_stop"),
1192
+ "non-length stopReasons: no length_stop dashboard event",
1193
+ );
1194
+ });
1195
+
1196
+ // ---- S29: percent-based auto-compact trigger (gate on context %, not tokens) -
1197
+ // The context-handler gate now fires on pct/100 >= (autoPctTrigger ?? tierPct)
1198
+ // for tiered configs, with a token FALLBACK when pct is null. `custom` keeps the
1199
+ // absolute token gate. These are the first tests to drive a `context` event
1200
+ // on a tiered config (the default harness forces custom via THRESHOLD_TOKENS=50).
1201
+
1202
+ /** S29 tiered-config helper: tiered (not custom), low tier (tierPct 0.5), with
1203
+ * the legacy durable-trim flag off + anchor floor lowered so the live trim
1204
+ * returns a trimmed view (mirrors the S16 live-trim test setup at ~line 329). */
1205
+ function s29TieredCtx(h: ReturnType<typeof harness>, usage: { tokens: number; contextWindow: number; percent: number | null }) {
1206
+ delete process.env.MEGACOMPACT_LEGACY_DURABLE_TRIM;
1207
+ delete process.env.MEGACOMPACT_DURABLE_TRIM_FLOOR;
1208
+ process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES = "1";
1209
+ return h.ctx({
1210
+ isIdle: () => true,
1211
+ hasPendingMessages: () => false,
1212
+ getContextUsage: () => usage as any,
1213
+ });
1214
+ }
1215
+
1216
+ test("S29: percent gate fires when tokens under-report (tiered low, percent 55, tokens 10)", async () => {
1217
+ process.env.MEGACOMPACT_TIER = "low";
1218
+ delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
1219
+ delete process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
1220
+ try {
1221
+ const h = harness({ keepTier: true, keepThreshold: true });
1222
+ // tokens=10 (under the 0.5×10000=5000 token gate), percent=55 (>= 0.5).
1223
+ // The OLD token-only gate would return (10 < 5000) → no trim. The S29
1224
+ // percent gate (0.55 >= 0.5) fires → live trim returns a trimmed view.
1225
+ const ctx = s29TieredCtx(h, { tokens: 10, contextWindow: 10000, percent: 55 });
1226
+ const res = await h.fire("context", { type: "context", messages: h.session }, ctx);
1227
+ assert.ok(res && typeof res === "object", "percent gate: live trim returned a result object");
1228
+ assert.ok(Array.isArray((res as any).messages), "percent gate: result has a trimmed messages array");
1229
+ assert.ok(
1230
+ (res as any).messages.length < h.session.length,
1231
+ "percent gate: trimmed view is shorter than the full session",
1232
+ );
1233
+ assert.equal(h.compactCalls.length, 0, "percent gate: live trim, no ctx.compact()");
1234
+
1235
+ // Control: percent 40 (< 0.5) → no trim, even with the same under-reported tokens.
1236
+ const h2 = harness({ keepTier: true, keepThreshold: true });
1237
+ const ctx2 = s29TieredCtx(h2, { tokens: 10, contextWindow: 10000, percent: 40 });
1238
+ const res2 = await h2.fire("context", { type: "context", messages: h2.session }, ctx2);
1239
+ assert.ok(
1240
+ !(res2 && typeof res2 === "object" && Array.isArray((res2 as any).messages)),
1241
+ "percent below fire point: no trim (token count 10 is also below the token gate)",
1242
+ );
1243
+ } finally {
1244
+ delete process.env.MEGACOMPACT_TIER;
1245
+ delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
1246
+ }
1247
+ });
1248
+
1249
+ test("S29: MEGACOMPACT_AUTO_PCT_TRIGGER overrides the tier fire point (0.85)", async () => {
1250
+ process.env.MEGACOMPACT_TIER = "low"; // tierPct 0.5
1251
+ process.env.MEGACOMPACT_AUTO_PCT_TRIGGER = "0.85";
1252
+ delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
1253
+ try {
1254
+ // percent 80 < 0.85 → no trim.
1255
+ const h = harness({ keepTier: true, keepThreshold: true });
1256
+ const ctx80 = s29TieredCtx(h, { tokens: 10, contextWindow: 10000, percent: 80 });
1257
+ const res80 = await h.fire("context", { type: "context", messages: h.session }, ctx80);
1258
+ assert.ok(
1259
+ !(res80 && typeof res80 === "object" && Array.isArray((res80 as any).messages)),
1260
+ "override 0.85: percent 80 does NOT trim (below the override fire point)",
1261
+ );
1262
+
1263
+ // percent 90 >= 0.85 → trim fires (despite the tier's own 0.5 fire point).
1264
+ const h2 = harness({ keepTier: true, keepThreshold: true });
1265
+ const ctx90 = s29TieredCtx(h2, { tokens: 10, contextWindow: 10000, percent: 90 });
1266
+ const res90 = await h2.fire("context", { type: "context", messages: h2.session }, ctx90);
1267
+ assert.ok(
1268
+ res90 && Array.isArray((res90 as any).messages) && (res90 as any).messages.length < h2.session.length,
1269
+ "override 0.85: percent 90 DOES trim (above the override fire point)",
1270
+ );
1271
+ } finally {
1272
+ delete process.env.MEGACOMPACT_TIER;
1273
+ delete process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
1274
+ delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
1275
+ }
1276
+ });
1277
+
1278
+ test("S29: custom tier keeps the absolute token gate (percent 40 but tokens 100 >= 50)", async () => {
1279
+ // MEGACOMPACT_THRESHOLD_TOKENS → custom (tierPct null) → token gate, percent ignored.
1280
+ process.env.MEGACOMPACT_THRESHOLD_TOKENS = "50";
1281
+ delete process.env.MEGACOMPACT_TIER;
1282
+ delete process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
1283
+ try {
1284
+ const h = harness({ keepTier: true, keepThreshold: true });
1285
+ // percent 40 (low) BUT tokens 100 >= 50 threshold → custom token gate fires.
1286
+ const ctx = s29TieredCtx(h, { tokens: 100, contextWindow: 10000, percent: 40 });
1287
+ const res = await h.fire("context", { type: "context", messages: h.session }, ctx);
1288
+ assert.ok(
1289
+ res && Array.isArray((res as any).messages) && (res as any).messages.length < h.session.length,
1290
+ "custom tier: token gate fires (tokens 100 >= 50) despite low percent 40",
1291
+ );
1292
+ } finally {
1293
+ delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
1294
+ delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
1295
+ }
1296
+ });
1297
+
1298
+ test("S29: tiered config with pct==null falls back to the token gate (not skipped)", async () => {
1299
+ // The regression guard for the audit finding: a percent-ONLY gate would skip
1300
+ // compaction when percent is unreported. S29 falls back to the token gate
1301
+ // (S27 boot-fallback guarantee). tiered low: effectiveThreshold = 0.5×10000 = 5000;
1302
+ // tokens 6000 >= 5000 → token fallback fires.
1303
+ process.env.MEGACOMPACT_TIER = "low";
1304
+ delete process.env.MEGACOMPACT_THRESHOLD_TOKENS;
1305
+ delete process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
1306
+ try {
1307
+ const h = harness({ keepTier: true, keepThreshold: true });
1308
+ const ctx = s29TieredCtx(h, { tokens: 6000, contextWindow: 10000, percent: null });
1309
+ const res = await h.fire("context", { type: "context", messages: h.session }, ctx);
1310
+ assert.ok(
1311
+ res && Array.isArray((res as any).messages) && (res as any).messages.length < h.session.length,
1312
+ "pct==null on tiered: token fallback fires (NOT skipped) — S27 boot-fallback preserved",
1313
+ );
1314
+ } finally {
1315
+ delete process.env.MEGACOMPACT_TIER;
1316
+ delete process.env.MEGACOMPACT_ANCHOR_USER_MESSAGES;
1317
+ }
1318
+ });
1319
+
1059
1320
  test("cleanup", async () => {
1060
1321
  // Terminate the global PGlite cross-repo index (WASM worker thread) so the
1061
1322
  // test process can exit. Without this, node --test never returns even though
@@ -66,6 +66,17 @@ export interface MegaConfig {
66
66
  auto: boolean;
67
67
  autoInline: boolean;
68
68
  autoInlineK: number;
69
+ /** S28: auto-continue the agent after a max-output-token length stop by
70
+ * reusing the existing S16 resume-nudge. Default true. Off = silent (the
71
+ * prior behavior). PREVENT-PI-003: restart via user-role sendUserMessage. */
72
+ autoContinueLengthStop: boolean;
73
+ /** S29: override the auto-compact fire point for tiered configs, as a
74
+ * fraction of the context window (e.g. 0.85). null = inherit the tier's
75
+ * tierPct (default; preserves existing fire points). The context-handler
76
+ * gate fires on context % (reliable), not token count (under-reported),
77
+ * so it catches the overshoot that causes max-output-token truncation.
78
+ * `custom` (tierPct null) ignores this — it keeps the absolute token gate. */
79
+ autoPctTrigger: number | null;
69
80
  dedupSim: number;
70
81
  /** RAPTOR hierarchical recall enabled (Fix D). Drives both live recall and
71
82
  * the durable-trim summary source (root summary). */
@@ -204,6 +215,15 @@ export {
204
215
  /** Build the resolved config from env + defaults. */
205
216
  export function loadConfig(): MegaConfig {
206
217
  const { tier, tierPct, thresholdTokens } = resolveThreshold();
218
+ // S29: optional percent-based fire-point override for tiered configs.
219
+ // null = inherit tierPct (default; preserves existing fire points). Clamped
220
+ // to [0.1, 1] so a bogus env can't disable or invert the gate. Ignored by
221
+ // the `custom` tier (tierPct null) which keeps the absolute token gate.
222
+ const aptRaw = process.env.MEGACOMPACT_AUTO_PCT_TRIGGER;
223
+ const autoPctTrigger =
224
+ aptRaw && aptRaw !== "" && Number.isFinite(Number(aptRaw))
225
+ ? Math.min(1, Math.max(0.1, Number(aptRaw)))
226
+ : null;
207
227
  return {
208
228
  tier,
209
229
  tierPct,
@@ -217,6 +237,8 @@ export function loadConfig(): MegaConfig {
217
237
  preserveRecentMin: envFlag("MEGACOMPACT_PRESERVE_RECENT_MIN", 2),
218
238
  auto: envBool("MEGACOMPACT_AUTO", true),
219
239
  autoInline: envBool("MEGACOMPACT_AUTO_INLINE", true),
240
+ autoContinueLengthStop: envBool("MEGACOMPACT_AUTO_CONTINUE_LENGTH_STOP", true),
241
+ autoPctTrigger,
220
242
  autoInlineK: envFlag("MEGACOMPACT_AUTO_INLINE_K", 3),
221
243
  dedupSim: Number(process.env.MEGACOMPACT_DEDUP_SIM ?? "0.9"),
222
244
  raptorEnabled: envBool("MEGACOMPACT_RAPTOR_ENABLED", true),
@@ -114,6 +114,23 @@ export interface DashboardSnapshot {
114
114
  duplicatesCollapsed: number; // dedup duplicates (original kept on survivor)
115
115
  bytesPermanentlyDeleted: number; // ALWAYS 0 — the invariant
116
116
  };
117
+ /** Cache-hit / recall-injection counters (live session + store-wide totals). */
118
+ cacheHits: {
119
+ session: number; // dedup skips + recall injections this session
120
+ total: number; // store-wide deduped collapses + recall injections
121
+ sessionTokensSaved: number; // tokens saved via cache hits this session
122
+ totalTokensSaved: number; // store-wide tokens saved via cache hits
123
+ };
124
+ /** Compaction counters (live session + store-wide cumulative). */
125
+ compacts: {
126
+ session: number; // compactions performed this session
127
+ total: number; // store-wide cumulative compaction count
128
+ };
129
+ /** Estimated wall-clock time saved (rough tokens/sec heuristic). */
130
+ timeSaved: {
131
+ compact: { sessionSec: number; totalSec: number };
132
+ cacheHit: { sessionSec: number; totalSec: number };
133
+ };
117
134
  /** Active model/provider (captured live) — shown on the current-repo card. */
118
135
  model?: {
119
136
  name: string; // Model.name or Model.id
@@ -40,6 +40,7 @@ import {
40
40
  import { computeLiveTrimCut, liveTrimSummaryMessage } from "./mega-trim.js";
41
41
  import {
42
42
  pressureFromPct,
43
+ pressureRatio,
43
44
  memoryReviewCadence,
44
45
  type MegaConfig,
45
46
  } from "./mega-config.js";
@@ -267,7 +268,7 @@ export function registerEventHandlers(
267
268
  // compaction AND there is queued work AND we haven't nudged recently, nudge
268
269
  // once so the agent continues (the live trim should make this rare). Guarded
269
270
  // to never busy-loop: one nudge per 30s, only when truly idle + queued.
270
- if (config.auto && runtime.activeAgents === 0) {
271
+ if ((config.auto || config.autoContinueLengthStop) && runtime.activeAgents === 0) {
271
272
  try {
272
273
  const idle = ctx.isIdle?.() ?? true;
273
274
  const queued = ctx.hasPendingMessages?.() ?? false;
@@ -316,7 +317,7 @@ export function registerEventHandlers(
316
317
  // most. Instead we DECOUPLE the nudge from `queued`: after a durable
317
318
  // trim we ALWAYS nudge so the agent reliably restarts. Debounced 30s.
318
319
  let didDurableTrim = false;
319
- if (idle && overThreshold && now >= runtime.debounceUntil) {
320
+ if (config.auto && idle && overThreshold && now >= runtime.debounceUntil) {
320
321
  // COMPACT-DEDUP FIX: skip the manual durable-trim trigger when pi's
321
322
  // NATIVE auto-compaction just fired (or is in-flight). pi emits
322
323
  // agent_end BEFORE its own _checkCompaction (per its docstring:
@@ -346,15 +347,28 @@ export function registerEventHandlers(
346
347
  // Restart the agent after a mid-run durable trim (which stopped it), or
347
348
  // when it settled idle with queued work. Decoupled from `queued` for the
348
349
  // durable-trim case — see FIX note above. Debounced 30s; never blocks.
350
+ const lengthStop = config.autoContinueLengthStop && runtime.rt.lengthStopPending;
349
351
  if (
350
352
  idle &&
351
353
  now >= runtime.resumeNudgeUntil &&
352
- (didDurableTrim || queued)
354
+ ((config.auto && (didDurableTrim || queued)) || lengthStop)
353
355
  ) {
354
356
  runtime.resumeNudgeUntil = now + 30_000;
355
- pi.sendUserMessage(
356
- "[mega-compact] continue from the compacted context above.",
357
- );
357
+ if (runtime.rt.lengthStopPending) {
358
+ runtime.rt.lengthStopPending = false; // one-shot: never re-fire for same stop
359
+ runtime.dashboard.event("length_stop_continue", { turnIndex: runtime.currentTurn });
360
+ runtime.logger.info("length_stop_continue", {
361
+ sessionId: runtime.rt.sessionId,
362
+ didDurableTrim,
363
+ queued,
364
+ });
365
+ }
366
+ // S28: when a length-stop (max-output-token truncation) fired WITHOUT a durable trim, do NOT claim a compaction happened
367
+ // (nothing was compacted on the low-pressure length path). Branch the message so the nudge matches reality.
368
+ const nudgeMsg = lengthStop && !didDurableTrim
369
+ ? "[mega-compact] the last response hit the output-token cap; continue from where it stopped."
370
+ : "[mega-compact] continue from the compacted context above.";
371
+ pi.sendUserMessage(nudgeMsg);
358
372
  }
359
373
  } catch {
360
374
  /* non-fatal: a failed nudge never blocks */
@@ -365,6 +379,7 @@ export function registerEventHandlers(
365
379
 
366
380
  pi.on("turn_start", async (event, ctx) => {
367
381
  runtime.currentTurn = event.turnIndex;
382
+ runtime.rt.lengthStopPending = false; // S28: re-arm defensively each user turn
368
383
  runtime.dashboard.event("turn_start", { turnIndex: event.turnIndex });
369
384
  runtime.snapshot(ctx);
370
385
  });
@@ -395,6 +410,18 @@ export function registerEventHandlers(
395
410
  await runMemoryReview(runtime, view, "turn");
396
411
  }
397
412
  }
413
+
414
+ // S28: detect max-output-token truncation. event.message.stopReason is the
415
+ // pi-ai StopReason union; 'length' == generation hit max_tokens OUTPUT cap
416
+ // (INPUT-orthogonal to context-window overflow). Arm the agent_end nudge.
417
+ if (
418
+ config.autoContinueLengthStop &&
419
+ event.message.role === "assistant" &&
420
+ event.message.stopReason === "length"
421
+ ) {
422
+ runtime.rt.lengthStopPending = true;
423
+ runtime.dashboard.event("length_stop", { turnIndex: event.turnIndex });
424
+ }
398
425
  });
399
426
 
400
427
  // ---- Auto-trigger: live trim (compact and continue) + native durable ----
@@ -420,14 +447,13 @@ export function registerEventHandlers(
420
447
  runtime.lastCtxPercent = pct ?? null;
421
448
  runtime.lastCtxWindow = usage?.contextWindow ?? 0;
422
449
  runtime.snapshot(ctx);
423
- if (pct == null) return;
424
450
 
425
451
  const messages = event.messages;
426
452
  const view = runtime.engineView(messages);
427
453
  const currentTokens =
428
454
  usage?.tokens ??
429
455
  estimateSessionTokens(view) ??
430
- Math.round((pct / 100) * (usage?.contextWindow ?? 0));
456
+ Math.round(((pct ?? 0) / 100) * (usage?.contextWindow ?? 0));
431
457
 
432
458
  // S27 DB-mirror: append ALL incoming messages to raw_transcript.
433
459
  // Runs BEFORE fast-gate so every message is captured, even if we
@@ -445,15 +471,35 @@ export function registerEventHandlers(
445
471
  }
446
472
  }
447
473
 
448
- // FAST GATE: token-based (tier% of the window), not a static amount.
449
- if (currentTokens < runtime.effectiveThreshold) {
450
- runtime.diagCtxFastGate++;
451
- return;
474
+ // S29 FAST GATE: drive the auto-trigger off the context % (the number the
475
+ // menu bar shows), NOT the token count — the model under-reports tokens,
476
+ // so a token-only gate misses the overshoot that causes max-output-token
477
+ // truncation. The fire point is the tier's percent threshold (tierPct)
478
+ // unless overridden by MEGACOMPACT_AUTO_PCT_TRIGGER. `custom` (absolute
479
+ // MEGACOMPACT_THRESHOLD_TOKENS, tierPct null) is an explicit opt-out of
480
+ // percent scaling — it keeps the token gate. When pct is unavailable
481
+ // (window unknown / a model that doesn't report percent) a tiered config
482
+ // falls back to the token gate (S27 boot-fallback guarantee) instead of
483
+ // skipping compaction — a percent-only gate would regress that.
484
+ let gatePassed = false;
485
+ if (config.tierPct != null && pct != null) {
486
+ const firePct = config.autoPctTrigger ?? config.tierPct;
487
+ gatePassed = pct / 100 >= firePct;
488
+ } else {
489
+ // custom tier OR tiered-but-pct-unavailable → token gate (S27 fallback).
490
+ if (currentTokens < runtime.effectiveThreshold) {
491
+ runtime.diagCtxFastGate++;
492
+ return;
493
+ }
494
+ const check = autoCompactCheck(currentTokens, runtime.effectiveThreshold); // SERVER-STYLE CONFIRM (local)
495
+ if (!check.shouldCompact) {
496
+ runtime.diagCtxNoCompact++;
497
+ return;
498
+ }
499
+ gatePassed = true;
452
500
  }
453
-
454
- const check = autoCompactCheck(currentTokens, runtime.effectiveThreshold); // SERVER-STYLE CONFIRM (local)
455
- if (!check.shouldCompact) {
456
- runtime.diagCtxNoCompact++;
501
+ if (!gatePassed) {
502
+ runtime.diagCtxFastGate++;
457
503
  return;
458
504
  }
459
505
 
@@ -466,8 +512,10 @@ export function registerEventHandlers(
466
512
  runtime.debounceUntil = now + 2000;
467
513
 
468
514
  // Adaptive compression (Fix E): scale compression strength + keepFrom depth
469
- // with how close we are to the model context limit.
470
- const pressure = pressureFromPct(pct);
515
+ // with how close we are to the model context limit. Null-safe: when the
516
+ // token-fallback path ran (pct unavailable) use the token-basis pressure
517
+ // (the same basis the runtime `pressure` getter uses for custom/no-window).
518
+ const pressure = pct != null ? pressureFromPct(pct) : pressureRatio(currentTokens, runtime.effectiveThreshold);
471
519
  const ran = runCompact(pi, runtime, config, ctx, messages, {
472
520
  compressionPressure: pressure,
473
521
  });
@@ -15,7 +15,7 @@ import type { EngineMessage } from "../src/types.js";
15
15
  import { recallAndInline, recallAndInlineAsync, formatRecallBlock, type RecallInjectResult } from "../src/recall.js";
16
16
  import { normalizeSessionId } from "../src/store.js";
17
17
  import { estimateBlockTokens } from "../src/tokens.js";
18
- import { touchSession, logDaily } from "../src/store/sqlite.js";
18
+ import { touchSession, logDaily, incCompactCount, incRecallInjected, incCacheHitTokens } from "../src/store/sqlite.js";
19
19
  import { consolidateMemories } from "../src/memory.js";
20
20
  import {
21
21
  type MegaRuntime,
@@ -146,6 +146,9 @@ function doCompact(
146
146
  ? result.originalTokenEstimate
147
147
  : Math.max(0, result.originalTokenEstimate - result.tokenEstimate);
148
148
  runtime.rt.tokensSaved += saved;
149
+ runtime.rt.compactCount += 1;
150
+ incCompactCount(runtime.currentStateDir);
151
+ if (result.deduped) { runtime.rt.cacheHitTokens += saved; incCacheHitTokens(saved, runtime.currentStateDir); }
149
152
  runtime.rt.lastCompactAt = Date.now();
150
153
  if (result.deduped) runtime.rt.dedupSkips++;
151
154
  // Grow the rolling "saved" goal so the progress bar always has a fresh
@@ -435,6 +438,13 @@ export function doRecall(
435
438
  runtime.pushTicker(`${C.amber}↩${C.reset} recalled ${top.checkpoint.checkpointId} · ${scorePct}% · ${label}`);
436
439
  runtime.lastWhy = `why: recalled@${scorePct}% (${result.toInject.length} chkpt)`;
437
440
  }
441
+ if (result.toInject.length > 0) {
442
+ let sumTokens = 0; for (const h of result.toInject) sumTokens += h.checkpoint.tokenEstimate;
443
+ runtime.rt.recallInjections += result.toInject.length;
444
+ runtime.rt.cacheHitTokens += sumTokens;
445
+ incRecallInjected(result.toInject.length, runtime.currentStateDir);
446
+ incCacheHitTokens(sumTokens, runtime.currentStateDir);
447
+ }
438
448
  return result;
439
449
  }
440
450
 
@@ -493,6 +503,13 @@ export async function doRecallAsync(
493
503
  if (!seen.has(h.checkpoint.checkpointId)) { merged.push(h); seen.add(h.checkpoint.checkpointId); }
494
504
  }
495
505
  const block = merged.length ? formatRecallBlock(merged) : "";
506
+ if (merged.length > 0) {
507
+ let sumTokens = 0; for (const h of merged) sumTokens += h.checkpoint.tokenEstimate;
508
+ runtime.rt.recallInjections += merged.length;
509
+ runtime.rt.cacheHitTokens += sumTokens;
510
+ incRecallInjected(merged.length, runtime.currentStateDir);
511
+ incCacheHitTokens(sumTokens, runtime.currentStateDir);
512
+ }
496
513
  return {
497
514
  toInject: merged,
498
515
  report: merged.map((h) => ` • ${h.checkpoint.checkpointId}${h.repoId ? ` (from ${h.repoId.split("/").filter(Boolean).pop()})` : ""}`),