pi-blackhole 0.2.1 → 0.2.2

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 CHANGED
@@ -131,6 +131,7 @@ The tradeoff is simplicity vs cleanliness:
131
131
  |---|---|---|
132
132
  | Workers run? | Yes | Yes |
133
133
  | Observations go to | Conversation markers (invisible in TUI) | Disk (`pending.json`) |
134
+ | Observations accumulate across runs | Branch markers (replaced each cycle) | Pending batches — `/memory` shows pending counts |
134
135
  | Auto-compact on `agent_end` | Yes | No |
135
136
  | `/blackhole` | Optional — use it whenever you want | Required to flush + compact |
136
137
  | Conversation history | OM marker entries between turns (they exist but do not clutter the display) | Clean — nothing between turns |
@@ -198,6 +199,7 @@ Quick start with custom models:
198
199
  | `reflectAfterTokens` | `20000` | Token cadence for reflector and dropper |
199
200
  | `compactAfterTokens` | `81000` | Auto-compaction threshold |
200
201
  | `observerChunkMaxTokens` | `40000` | Max observer input per run (newest-first) |
202
+ | `observerPreambleMaxTokens` | `0` (auto) | Max preamble tokens in observer prompt for `noAutoCompact` mode (auto = 30% of chunk) |
201
203
  | `observationsPoolMaxTokens` | `20000` | Max active observation pool before dropper prunes |
202
204
  | `reflectorInputMaxTokens` | `80000` | Max reflector input budget |
203
205
  | `dropperInputMaxTokens` | `80000` | Max dropper input budget |
@@ -220,6 +222,7 @@ Paste the appropriate block into your config to match your model's context size.
220
222
  "reflectAfterTokens": 10000,
221
223
  "compactAfterTokens": 30000,
222
224
  "observerChunkMaxTokens": 15000,
225
+ "observerPreambleMaxTokens": 0,
223
226
  "observationsPoolMaxTokens": 8000,
224
227
  "reflectorInputMaxTokens": 30000,
225
228
  "dropperInputMaxTokens": 30000
@@ -236,6 +239,7 @@ Our built-in defaults already target this tier. If you reset your config, these
236
239
  "reflectAfterTokens": 20000,
237
240
  "compactAfterTokens": 81000,
238
241
  "observerChunkMaxTokens": 40000,
242
+ "observerPreambleMaxTokens": 0,
239
243
  "observationsPoolMaxTokens": 20000,
240
244
  "reflectorInputMaxTokens": 80000,
241
245
  "dropperInputMaxTokens": 80000
@@ -250,6 +254,7 @@ Our built-in defaults already target this tier. If you reset your config, these
250
254
  "reflectAfterTokens": 40000,
251
255
  "compactAfterTokens": 180000,
252
256
  "observerChunkMaxTokens": 80000,
257
+ "observerPreambleMaxTokens": 0,
253
258
  "observationsPoolMaxTokens": 40000,
254
259
  "reflectorInputMaxTokens": 160000,
255
260
  "dropperInputMaxTokens": 160000
@@ -86,13 +86,14 @@
86
86
  }
87
87
  ],
88
88
 
89
- "observeAfterTokens": 10000,
90
- "reflectAfterTokens": 20000,
89
+ "observeAfterTokens": 15000,
90
+ "reflectAfterTokens": 25000,
91
91
  "compactAfterTokens": 81000,
92
92
  "observationsPoolMaxTokens": 20000,
93
93
  "reflectorInputMaxTokens": 80000,
94
94
  "dropperInputMaxTokens": 80000,
95
95
  "observerChunkMaxTokens": 40000,
96
+ "observerPreambleMaxTokens": 0,
96
97
  "agentMaxTurns": 16,
97
98
 
98
99
  "passive": false,
@@ -110,6 +111,16 @@
110
111
  "After all candidates exhausted, the stage aborts. 30s retry gate prevents spam.",
111
112
  "Cooldowns persist in ~/.pi/agent/pi-blackhole/pi-blackhole-cooldown.json (survives pi restarts).",
112
113
  "Valid thinking values: off, minimal, low, medium, high, xhigh.",
113
- "Env override for passive mode: PI_BLACKHOLE_PASSIVE=true."
114
+ "Env override for passive mode: PI_BLACKHOLE_PASSIVE=true.",
115
+ "",
116
+ "noAutoCompact mode accumulates observationBatches/reflectionBatches in",
117
+ "pending.json across pipeline runs. The observer preamble (CURRENT OBSERVATIONS)",
118
+ "is capped to observerPreambleMaxTokens (default 0 = auto 30% of observerChunkMaxTokens).",
119
+ "This is a soft safety valve — it only trims when accumulated observations exceed",
120
+ "the budget. High-relevance observations are always kept. Reflections are never trimmed.",
121
+ "",
122
+ "agentMaxTurns controls how many agent-loop turns each worker gets per chunk.",
123
+ "Lower-context models can reduce this (e.g., 8) so the observer doesn't waste",
124
+ "turns trying to extract more than it can reliably handle per run."
114
125
  ]
115
126
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-blackhole",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Unified compaction + observational memory extension for Pi — compresses conversation context while preserving durable observations and reflections",
5
5
  "license": "MIT",
6
6
  "main": "index.ts",
@@ -164,11 +164,12 @@ export function registerMemoryCommand(pi: ExtensionAPI, runtime: Runtime): void
164
164
  observationLine,
165
165
  reflectionLine,
166
166
  "",
167
- "── Activity ──",
168
- `Observer: ~${obsProgress.toLocaleString()} / ${runtime.config.observeAfterTokens.toLocaleString()} tokens (${pct(obsProgress, runtime.config.observeAfterTokens)}%)`,
169
- `Reflector: ~${reflectionProgress.toLocaleString()} / ${runtime.config.reflectAfterTokens.toLocaleString()} tokens (${pct(reflectionProgress, runtime.config.reflectAfterTokens)}%)`,
170
- `Dropper: ~${dropProgress.toLocaleString()} / ${runtime.config.reflectAfterTokens.toLocaleString()} tokens (${pct(dropProgress, runtime.config.reflectAfterTokens)}%)`,
171
- `Compaction: ~${compactionProgress.toLocaleString()} / ${runtime.config.compactAfterTokens.toLocaleString()} tokens (${pct(compactionProgress, runtime.config.compactAfterTokens)}%)`,
167
+ "── Pipeline ──",
168
+ "Transcript accumulated since last run. Triggers when exceeding threshold.",
169
+ `Observer: ~${obsProgress.toLocaleString()} tokens (triggers at ${runtime.config.observeAfterTokens.toLocaleString()})`,
170
+ `Reflector: ~${reflectionProgress.toLocaleString()} tokens (triggers at ${runtime.config.reflectAfterTokens.toLocaleString()})`,
171
+ `Dropper: ~${dropProgress.toLocaleString()} tokens (triggers at ${runtime.config.reflectAfterTokens.toLocaleString()})`,
172
+ `Compaction: ~${compactionProgress.toLocaleString()} tokens` + (runtime.config.noAutoCompact ? " [auto-disabled]" : ` (triggers at ${runtime.config.compactAfterTokens.toLocaleString()})`),
172
173
  `Obs pool: ~${visibleObservationTokens.toLocaleString()} / ${runtime.config.observationsPoolMaxTokens.toLocaleString()} tokens (${pct(visibleObservationTokens, runtime.config.observationsPoolMaxTokens)}%)`,
173
174
  `Reflect pool: ~${visibleReflectionTokens.toLocaleString()} tokens`,
174
175
  ];
@@ -184,6 +185,13 @@ export function registerMemoryCommand(pi: ExtensionAPI, runtime: Runtime): void
184
185
  if (hasObs) lines.push("Observation: waiting in pending.json");
185
186
  if (hasRef) lines.push("Reflection: waiting in pending.json");
186
187
  if (hasDrop) lines.push("Dropper: waiting in pending.json");
188
+ const preambleCap = runtime.config.observerPreambleMaxTokens > 0
189
+ ? runtime.config.observerPreambleMaxTokens
190
+ : Math.round(runtime.config.observerChunkMaxTokens * 0.3);
191
+ const pctNote = runtime.config.observerPreambleMaxTokens > 0
192
+ ? ""
193
+ : ` (30% of ${runtime.config.observerChunkMaxTokens.toLocaleString()} chunk)`;
194
+ lines.push(`Preamble cap: ${preambleCap.toLocaleString()} tokens for observations${pctNote}`);
187
195
  lines.push("Run /blackhole to flush and compact.");
188
196
  }
189
197
  }
@@ -55,14 +55,29 @@ export const registerPiVccCommand = (pi: ExtensionAPI, runtime: Runtime) => {
55
55
  // before compacting so the summary includes accumulated memory.
56
56
  if (runtime.config.noAutoCompact && hasPendingData(sessionId)) {
57
57
  const pending = readPendingState(sessionId);
58
- if (pending.observation) {
59
- pi.appendEntry(OM_OBSERVATIONS_RECORDED, pending.observation.data);
58
+ // Write all accumulated observation batches (or latest single batch
59
+ // as fallback for legacy pending.json without batch arrays).
60
+ const obsBatches = pending.observationBatches?.length
61
+ ? pending.observationBatches
62
+ : (pending.observation ? [pending.observation] : []);
63
+ for (const batch of obsBatches) {
64
+ pi.appendEntry(OM_OBSERVATIONS_RECORDED, batch.data);
60
65
  }
61
- if (pending.reflection) {
62
- pi.appendEntry(OM_REFLECTIONS_RECORDED, pending.reflection.data);
66
+ // Write all accumulated reflection batches (or latest single batch
67
+ // as fallback for legacy pending.json without batch arrays).
68
+ const reflBatches = pending.reflectionBatches?.length
69
+ ? pending.reflectionBatches
70
+ : (pending.reflection ? [pending.reflection] : []);
71
+ for (const batch of reflBatches) {
72
+ pi.appendEntry(OM_REFLECTIONS_RECORDED, batch.data);
63
73
  }
64
- if (pending.dropped) {
65
- pi.appendEntry(OM_OBSERVATIONS_DROPPED, pending.dropped.data);
74
+ // Write all accumulated dropper batches (or latest single batch
75
+ // as fallback for legacy pending.json without batch arrays).
76
+ const dropBatches = pending.droppedBatches?.length
77
+ ? pending.droppedBatches
78
+ : (pending.dropped ? [pending.dropped] : []);
79
+ for (const batch of dropBatches) {
80
+ pi.appendEntry(OM_OBSERVATIONS_DROPPED, batch.data);
66
81
  }
67
82
  clearPendingState(sessionId);
68
83
  ctx.ui.notify("Observational memory: pending entries flushed", "info");
@@ -50,6 +50,11 @@ export interface UnifiedConfig {
50
50
  dropperInputMaxTokens: number;
51
51
  /** Max source entries tokens sent to observer per chunk. */
52
52
  observerChunkMaxTokens: number;
53
+ /** Max preamble tokens (CURRENT REFLECTIONS / OBSERVATIONS) in the observer prompt.
54
+ * Default 0 means auto-compute from observerChunkMaxTokens (30%). Only applied in
55
+ * noAutoCompact mode where accumulated batch history can grow unbounded.
56
+ * Set to an explicit value to override the auto-computed budget. */
57
+ observerPreambleMaxTokens: number;
53
58
  /** Shared turn cap for background memory agents. */
54
59
  agentMaxTurns: number;
55
60
 
@@ -87,13 +92,14 @@ export const DEFAULTS: UnifiedConfig = {
87
92
  overrideDefaultCompaction: false,
88
93
  debug: false,
89
94
 
90
- observeAfterTokens: 10_000,
91
- reflectAfterTokens: 20_000,
95
+ observeAfterTokens: 15_000,
96
+ reflectAfterTokens: 25_000,
92
97
  compactAfterTokens: 81_000,
93
98
  observationsPoolMaxTokens: 20_000,
94
99
  reflectorInputMaxTokens: 80_000,
95
100
  dropperInputMaxTokens: 80_000,
96
101
  observerChunkMaxTokens: 40_000,
102
+ observerPreambleMaxTokens: 0,
97
103
  agentMaxTurns: 16,
98
104
 
99
105
  noAutoCompact: false,
@@ -154,7 +160,7 @@ function parseConfig(raw: Record<string, unknown>): Partial<UnifiedConfig> {
154
160
  if (typeof raw.debugLog === "boolean") c.debugLog = raw.debugLog;
155
161
 
156
162
  // Positive integers
157
- const numKeys = ["observeAfterTokens", "reflectAfterTokens", "compactAfterTokens", "observationsPoolMaxTokens", "reflectorInputMaxTokens", "dropperInputMaxTokens", "observerChunkMaxTokens", "agentMaxTurns"] as const;
163
+ const numKeys = ["observeAfterTokens", "reflectAfterTokens", "compactAfterTokens", "observationsPoolMaxTokens", "reflectorInputMaxTokens", "dropperInputMaxTokens", "observerChunkMaxTokens", "observerPreambleMaxTokens", "agentMaxTurns"] as const;
158
164
  for (const k of numKeys) {
159
165
  const v = positiveInt(raw[k]);
160
166
  if (v !== undefined) (c as Record<string, unknown>)[k] = v;
@@ -50,6 +50,7 @@ import {
50
50
  reflectionToSummaryLine,
51
51
  reflectionsCreatedAfterIndex,
52
52
  type Entry,
53
+ type Observation,
53
54
  type Reflection,
54
55
  } from "./ledger/index.js";
55
56
 
@@ -124,6 +125,82 @@ function mergeReflections(existing: Reflection[], additional: Reflection[]): Ref
124
125
  return merged;
125
126
  }
126
127
 
128
+ /** Score an observation for preamble cap selection.
129
+ * Relevance tier dominates: medium (5+) always outranks low (max 2).
130
+ * Recency is based on position in the flat-mapped array (0 = oldest, N-1 = newest),
131
+ * avoiding wall-clock dependency that punishes sessions spanning days or weeks. */
132
+ function scoreObservation(obs: Observation, index: number, total: number): number {
133
+ const base = obs.relevance === "high" || obs.relevance === "critical" ? 10
134
+ : obs.relevance === "medium" ? 5 : 1;
135
+ const recency = total > 1 ? index / (total - 1) : 1;
136
+ return base + recency;
137
+ }
138
+
139
+ /** Select observations for the observer preamble, keeping all high-relevance items
140
+ * unconditionally and filling the remaining token budget with the best-scoring
141
+ * medium and low observations (relevance-tiered + recency).
142
+ *
143
+ * Reflections are never trimmed — they are inherently rare and always stay. */
144
+ function selectPriorObservations(observations: Observation[], maxTokens: number): Observation[] {
145
+ // Track original indices so we can restore chronological order after scoring
146
+ const indexed = observations.map((obs, i) => ({ obs, originalIndex: i }));
147
+ const high = indexed.filter(item => item.obs.relevance === "high" || item.obs.relevance === "critical");
148
+ const rest = indexed.filter(item => item.obs.relevance !== "high" && item.obs.relevance !== "critical");
149
+
150
+ // High always kept — consume budget first
151
+ let budget = maxTokens;
152
+ const selected = new Set<{ obs: Observation; originalIndex: number }>();
153
+ for (const item of high) {
154
+ const lineTokens = Math.ceil(observationToSummaryLine(item.obs).length / 4);
155
+ selected.add(item);
156
+ budget -= lineTokens;
157
+ }
158
+
159
+ // Score medium + low and select best within remaining budget
160
+ if (rest.length > 0 && budget > 0) {
161
+ const scored = rest.map((item, i) => ({ item, score: scoreObservation(item.obs, i, rest.length) }));
162
+ scored.sort((a, b) => b.score - a.score); // highest score first
163
+ for (const { item } of scored) {
164
+ const lineTokens = Math.ceil(observationToSummaryLine(item.obs).length / 4);
165
+ if (budget - lineTokens < 0) break;
166
+ selected.add(item);
167
+ budget -= lineTokens;
168
+ }
169
+ }
170
+
171
+ // Restore original chronological order before returning
172
+ return Array.from(selected)
173
+ .sort((a, b) => a.originalIndex - b.originalIndex)
174
+ .map(item => item.obs);
175
+ }
176
+
177
+ /**
178
+ * Extract all pending observations from accumulated batches that were recorded
179
+ * after a given coverage ID (e.g., the last reflection or drop coverage ID).
180
+ * This is needed in noAutoCompact mode because the reflector/dropper may skip
181
+ * a pipeline cycle, leaving unprocessed batches in observationBatches that
182
+ * should still be served as "new" on subsequent runs.
183
+ */
184
+ function pendingObservationsCreatedAfter(
185
+ pending: any,
186
+ entries: Entry[],
187
+ afterCoversUpToId: string | undefined,
188
+ ): Observation[] {
189
+ const batches = pending.observationBatches ?? [];
190
+ if (!afterCoversUpToId || entryIndexForId(entries, afterCoversUpToId) < 0) {
191
+ return batches.flatMap((b: any) => (b.data as any)?.observations ?? []);
192
+ }
193
+ const afterIdx = entryIndexForId(entries, afterCoversUpToId);
194
+ const newObs: Observation[] = [];
195
+ for (const batch of batches) {
196
+ const batchIdx = entryIndexForId(entries, batch.coversUpToId);
197
+ if (batchIdx >= 0 && batchIdx > afterIdx) {
198
+ newObs.push(...((batch.data as any)?.observations ?? []));
199
+ }
200
+ }
201
+ return newObs;
202
+ }
203
+
127
204
  function anyStageDue(entries: Entry[], runtime: Runtime): boolean {
128
205
  return rawTokensSinceObservationCoverage(entries) >= runtime.config.observeAfterTokens
129
206
  || rawTokensSinceReflectionCoverage(entries) >= runtime.config.reflectAfterTokens
@@ -272,12 +349,40 @@ async function runObserverStage(
272
349
  if (!chunk.trim() || sourceEntryIds.length === 0) return "continue";
273
350
  const chunkTokens = Math.ceil(chunk.length / 4);
274
351
 
352
+ const sessionId = ctx.sessionManager.getSessionId();
353
+
275
354
  const memory = fullProjection(entries);
276
- const priorReflections = memory.reflections.map(reflectionToSummaryLine);
277
- const priorObservations = memory.observations.map(observationToSummaryLine);
355
+ let priorReflections = memory.reflections.map(reflectionToSummaryLine);
356
+ let priorObservations = memory.observations.map(observationToSummaryLine);
357
+
358
+ // In noAutoCompact, append accumulated batch history to whatever
359
+ // fullProjection found in the branch (preserving pre-switch markers
360
+ // when transitioning from autoCompact to noAutoCompact mid-session).
361
+ // The preamble is capped via observerPreambleMaxTokens so accumulated
362
+ // observations don't grow unbounded across turns.
363
+ if (runtime.config.noAutoCompact) {
364
+ const pendingCtx = readPendingState(sessionId);
365
+ const accumulatedReflections = (pendingCtx.reflectionBatches ?? [])
366
+ .flatMap(b => (b.data as any).reflections ?? []);
367
+ const accumulatedObservations = (pendingCtx.observationBatches ?? [])
368
+ .flatMap(b => (b.data as any).observations ?? []);
369
+
370
+ // Capped preamble: high always kept, medium/low scored by relevance + recency
371
+ const preambleMaxTokens = runtime.config.observerPreambleMaxTokens > 0
372
+ ? runtime.config.observerPreambleMaxTokens
373
+ : Math.round(runtime.config.observerChunkMaxTokens * 0.3);
374
+ const allObservations = [...memory.observations, ...accumulatedObservations];
375
+ priorObservations = selectPriorObservations(allObservations, preambleMaxTokens)
376
+ .map(observationToSummaryLine);
377
+
378
+ // Reflections are never trimmed — rare and always kept
379
+ priorReflections = [
380
+ ...priorReflections,
381
+ ...accumulatedReflections.map(reflectionToSummaryLine),
382
+ ];
383
+ }
278
384
 
279
385
  // If noAutoCompact: skip if this exact chunk was already processed
280
- const sessionId = ctx.sessionManager.getSessionId();
281
386
  if (runtime.config.noAutoCompact && isObservationChunkPending(sessionId, coversUpToId)) {
282
387
  debugLog("observer.pending_skip", { coversUpToId, sessionId });
283
388
  return "continue";
@@ -379,11 +484,33 @@ async function runReflectorStage(
379
484
  ): Promise<ReflectorStageResult> {
380
485
  const sessionId = ctx.sessionManager.getSessionId();
381
486
  const entries = ctx.sessionManager.getBranch() as Entry[];
382
- const reflectionTokens = rawTokensSinceReflectionCoverage(entries);
383
- if (reflectionTokens < runtime.config.reflectAfterTokens) return { outcome: "continue", sameRunReflections: [] };
384
-
385
- const observationCoverageId = latestCoverageMarkerId(entries, OM_OBSERVATIONS_RECORDED);
386
- if (!observationCoverageId) return { outcome: "continue", sameRunReflections: [] };
487
+ let reflectionTokens: number;
488
+ let observationCoverageId: string | undefined;
489
+ if (runtime.config.noAutoCompact) {
490
+ const pending = readPendingState(sessionId);
491
+ // Check any accumulated batch for unprocessed observations, not just the latest
492
+ const hasPendingObs = (pending.observationBatches ?? []).some((b: any) => (b.data as any)?.observations?.length);
493
+ if (!hasPendingObs) return { outcome: "continue", sameRunReflections: [] };
494
+ observationCoverageId = pending.observation?.coversUpToId;
495
+ if (pending.reflection?.coversUpToId) {
496
+ const obsIdx = entryIndexForId(entries, pending.observation?.coversUpToId ?? "");
497
+ const refIdx = entryIndexForId(entries, pending.reflection.coversUpToId);
498
+ if (obsIdx >= 0 && refIdx >= 0 && obsIdx <= refIdx) return { outcome: "continue", sameRunReflections: [] };
499
+ if (refIdx >= 0) {
500
+ reflectionTokens = rawTokensAfterIndex(entries, refIdx);
501
+ if (reflectionTokens < runtime.config.reflectAfterTokens) return { outcome: "continue", sameRunReflections: [] };
502
+ } else {
503
+ reflectionTokens = rawTokensSinceObservationCoverage(entries);
504
+ }
505
+ } else {
506
+ reflectionTokens = rawTokensSinceObservationCoverage(entries);
507
+ }
508
+ } else {
509
+ reflectionTokens = rawTokensSinceReflectionCoverage(entries);
510
+ if (reflectionTokens < runtime.config.reflectAfterTokens) return { outcome: "continue", sameRunReflections: [] };
511
+ observationCoverageId = latestCoverageMarkerId(entries, OM_OBSERVATIONS_RECORDED);
512
+ if (!observationCoverageId) return { outcome: "continue", sameRunReflections: [] };
513
+ }
387
514
 
388
515
  for (let attempt = 0; attempt < MAX_STAGE_ATTEMPTS; attempt++) {
389
516
  const resolved = await resolveModel("reflector");
@@ -391,12 +518,15 @@ async function runReflectorStage(
391
518
 
392
519
  // Compute ahead for an accurate notification
393
520
  const folded = foldLedger(entries);
394
- const lastReflectionIdx = latestCoverageIndex(entries, OM_REFLECTIONS_RECORDED);
395
- const newObservations = observationsCreatedAfterIndex(entries, lastReflectionIdx);
396
- const newReflections = reflectionsCreatedAfterIndex(entries, lastReflectionIdx);
521
+ const pending = runtime.config.noAutoCompact ? readPendingState(sessionId) : undefined;
522
+ const lastReflectionIdx = pending ? -1 : latestCoverageIndex(entries, OM_REFLECTIONS_RECORDED);
523
+ const newObservations = pending
524
+ ? pendingObservationsCreatedAfter(pending, entries, pending.reflection?.coversUpToId)
525
+ : observationsCreatedAfterIndex(entries, lastReflectionIdx);
526
+ const newReflections = pending ? [] : reflectionsCreatedAfterIndex(entries, lastReflectionIdx);
397
527
  const newItemsTokens = Math.ceil(
398
- (newObservations.reduce((s, o) => s + o.content.length, 0) +
399
- newReflections.reduce((s, r) => s + r.content.length, 0)) / 4
528
+ (newObservations.reduce((s: number, o: any) => s + o.content.length, 0) +
529
+ newReflections.reduce((s: number, r: any) => s + r.content.length, 0)) / 4
400
530
  );
401
531
  const summaryBudget = Math.floor(runtime.config.reflectorInputMaxTokens * 0.15) * 2;
402
532
  const reflectorInputTokens = Math.min(newItemsTokens + summaryBudget, runtime.config.reflectorInputMaxTokens);
@@ -414,13 +544,21 @@ async function runReflectorStage(
414
544
  // Resolve thinking level for the specific model (fallbacks may have their own thinking config)
415
545
  const stageModelForThinking = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "reflector"), stageFallbacks: stageFallbackModels(runtime, "reflector") });
416
546
  try {
417
- // Existing memory summaries for context (capped)
547
+ // Existing memory summaries for context (capped).
548
+ // In noAutoCompact, merge accumulated pending batches with
549
+ // branch data (preserving pre-switch markers).
550
+ const sourceReflections = pending
551
+ ? [...folded.reflections, ...(pending.reflectionBatches ?? []).flatMap((b: any) => (b.data as any)?.reflections ?? [])]
552
+ : folded.reflections;
553
+ const sourceObservations = pending
554
+ ? [...folded.activeObservations, ...(pending.observationBatches ?? []).flatMap((b: any) => (b.data as any)?.observations ?? [])]
555
+ : folded.activeObservations;
418
556
  const existingReflectionsSummary = buildExistingReflectionsSummary(
419
- folded.reflections,
557
+ sourceReflections,
420
558
  Math.floor(runtime.config.reflectorInputMaxTokens * 0.15),
421
559
  );
422
560
  const existingObservationsSummary = buildExistingObservationsSummary(
423
- folded.activeObservations.filter(o => !newObservations.some(no => no.id === o.id)),
561
+ sourceObservations.filter((o: any) => !newObservations.some((no: any) => no.id === o.id)),
424
562
  Math.floor(runtime.config.reflectorInputMaxTokens * 0.15),
425
563
  );
426
564
 
@@ -437,6 +575,7 @@ async function runReflectorStage(
437
575
  });
438
576
 
439
577
  if (!reflections || reflections.length === 0) return { outcome: "continue", sameRunReflections: [] };
578
+ if (!observationCoverageId) return { outcome: "continue", sameRunReflections: [] };
440
579
 
441
580
  const data = buildReflectionsRecordedData(reflections, observationCoverageId);
442
581
  if (!data) return { outcome: "continue", sameRunReflections: [] };
@@ -474,11 +613,33 @@ async function runDropperStage(
474
613
  ): Promise<StageOutcome> {
475
614
  const sessionId = ctx.sessionManager.getSessionId();
476
615
  const entries = ctx.sessionManager.getBranch() as Entry[];
477
- const dropTokens = rawTokensSinceDropCoverage(entries);
478
- if (dropTokens < runtime.config.reflectAfterTokens) return "continue";
479
-
480
- const observationCoverageId = latestCoverageMarkerId(entries, OM_OBSERVATIONS_RECORDED);
481
- if (!observationCoverageId) return "continue";
616
+ let dropTokens: number;
617
+ let observationCoverageId: string | undefined;
618
+ if (runtime.config.noAutoCompact) {
619
+ const pending = readPendingState(sessionId);
620
+ // Check any accumulated batch for unprocessed observations, not just the latest
621
+ const hasPendingObs = (pending.observationBatches ?? []).some((b: any) => (b.data as any)?.observations?.length);
622
+ if (!hasPendingObs) return "continue";
623
+ observationCoverageId = pending.observation?.coversUpToId;
624
+ if (pending.dropped?.coversUpToId) {
625
+ const obsIdx = entryIndexForId(entries, pending.observation?.coversUpToId ?? "");
626
+ const dropIdx = entryIndexForId(entries, pending.dropped.coversUpToId);
627
+ if (obsIdx >= 0 && dropIdx >= 0 && obsIdx <= dropIdx) return "continue";
628
+ if (dropIdx >= 0) {
629
+ dropTokens = rawTokensAfterIndex(entries, dropIdx);
630
+ if (dropTokens < runtime.config.reflectAfterTokens) return "continue";
631
+ } else {
632
+ dropTokens = rawTokensSinceDropCoverage(entries);
633
+ }
634
+ } else {
635
+ dropTokens = rawTokensSinceDropCoverage(entries);
636
+ }
637
+ } else {
638
+ dropTokens = rawTokensSinceDropCoverage(entries);
639
+ if (dropTokens < runtime.config.reflectAfterTokens) return "continue";
640
+ observationCoverageId = latestCoverageMarkerId(entries, OM_OBSERVATIONS_RECORDED);
641
+ if (!observationCoverageId) return "continue";
642
+ }
482
643
 
483
644
  for (let attempt = 0; attempt < MAX_STAGE_ATTEMPTS; attempt++) {
484
645
  const resolved = await resolveModel("dropper");
@@ -486,10 +647,13 @@ async function runDropperStage(
486
647
 
487
648
  // Compute ahead for an accurate notification
488
649
  const folded = foldLedger(entries);
489
- const lastDropIdx = latestCoverageIndex(entries, OM_OBSERVATIONS_DROPPED);
490
- const newObservations = observationsCreatedAfterIndex(entries, lastDropIdx);
650
+ const pending = runtime.config.noAutoCompact ? readPendingState(sessionId) : undefined;
651
+ const lastDropIdx = pending ? -1 : latestCoverageIndex(entries, OM_OBSERVATIONS_DROPPED);
652
+ const newObservations = pending
653
+ ? pendingObservationsCreatedAfter(pending, entries, pending.dropped?.coversUpToId)
654
+ : observationsCreatedAfterIndex(entries, lastDropIdx);
491
655
  const dropperNewObsTokens = Math.ceil(
492
- newObservations.reduce((s, o) => s + o.content.length, 0) / 4
656
+ newObservations.reduce((s: number, o: any) => s + o.content.length, 0) / 4
493
657
  );
494
658
  const dropperSummaryBudget = Math.floor(runtime.config.dropperInputMaxTokens * 0.2);
495
659
  const dropperInputTokens = Math.min(dropperNewObsTokens + dropperSummaryBudget, runtime.config.dropperInputMaxTokens);
@@ -505,12 +669,23 @@ async function runDropperStage(
505
669
  if (ctx.hasUI) ctx.ui?.notify(`Observational memory: dropper running (~${effectiveDropTokens.toLocaleString()} tokens accumulated, ~${dropperInputTokens.toLocaleString()}-token input)`, "info");
506
670
 
507
671
  try {
508
- // Existing active observations summary for context (capped)
672
+ // Existing active observations summary for context (capped).
673
+ // In noAutoCompact, merge accumulated pending batches with
674
+ // branch data (preserving pre-switch markers).
675
+ const sourceObsForDropper = pending
676
+ ? [...folded.activeObservations, ...(pending.observationBatches ?? []).flatMap((b: any) => (b.data as any)?.observations ?? [])]
677
+ : folded.activeObservations;
509
678
  const existingObservationsSummary = buildExistingObservationsSummary(
510
- folded.activeObservations.filter(o => !newObservations.some(no => no.id === o.id)),
679
+ sourceObsForDropper.filter((o: any) => !newObservations.some((no: any) => no.id === o.id)),
511
680
  Math.floor(runtime.config.dropperInputMaxTokens * 0.2),
512
681
  );
513
- const reflectionsForDropper = mergeReflections(folded.reflections, sameRunReflections);
682
+ // In noAutoCompact, merge accumulated reflection batches with
683
+ // branch data (preserving pre-switch markers), matching the
684
+ // dropper's full autoCompact context.
685
+ const pendingReflections = pending
686
+ ? [...folded.reflections, ...(pending.reflectionBatches ?? []).flatMap((b: any) => (b.data as any)?.reflections ?? [])]
687
+ : folded.reflections;
688
+ const reflectionsForDropper = mergeReflections(pendingReflections, sameRunReflections);
514
689
 
515
690
  // Resolve thinking level for the specific model (fallbacks may have their own thinking config)
516
691
  const stageModelForThinking = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "dropper"), stageFallbacks: stageFallbackModels(runtime, "dropper") });
@@ -525,7 +700,9 @@ async function runDropperStage(
525
700
  maxTurns: runtime.config.agentMaxTurns,
526
701
  thinkingLevel: stageThinkingLevel(runtime, "dropper", stageModelForThinking),
527
702
  });
528
- const latestReflectionCoverageId = latestCoverageMarkerId(entries, OM_REFLECTIONS_RECORDED);
703
+ const latestReflectionCoverageId = runtime.config.noAutoCompact
704
+ ? pending?.reflection?.coversUpToId
705
+ : latestCoverageMarkerId(entries, OM_REFLECTIONS_RECORDED);
529
706
  const effectiveReflectionCoverageId = sameRunReflectionCoverageId ?? latestReflectionCoverageId;
530
707
  const coversUpToId = earlierCoverageMarkerId(entries, observationCoverageId, effectiveReflectionCoverageId);
531
708
  const data = coversUpToId && droppedIds ? buildObservationsDroppedData(droppedIds, coversUpToId) : undefined;
package/src/om/pending.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
17
17
  import { dirname, join } from "node:path";
18
18
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
19
+ import type { Observation, Reflection } from "./ledger/types.js";
19
20
 
20
21
  // ── Types ───────────────────────────────────────────────────────────────────
21
22
 
@@ -41,6 +42,25 @@ export interface PendingOMState {
41
42
  reflection?: PendingReflection;
42
43
  /** Latest dropper run (replaced each time, not accumulated). */
43
44
  dropped?: PendingDropped;
45
+ /**
46
+ * All observation batches accumulated across noAutoCompact pipeline runs.
47
+ * Each batch preserves per-run coverage (coversUpToId) matching the normal
48
+ * branch-marker pattern. Used for LLM context and /blackhole flush.
49
+ */
50
+ observationBatches?: PendingObservation[];
51
+ /**
52
+ * All reflection batches accumulated across noAutoCompact pipeline runs.
53
+ * Preserves per-run coverage for LLM context and /blackhole flush.
54
+ */
55
+ reflectionBatches?: PendingReflection[];
56
+ /**
57
+ * All dropper batches accumulated across noAutoCompact pipeline runs.
58
+ * Each batch preserves which observations were dropped in that run.
59
+ * Without accumulation, earlier drops are lost when the next dropper
60
+ * run overwrites pending.dropped, causing them to be "un-dropped" on
61
+ * /blackhole flush.
62
+ */
63
+ droppedBatches?: PendingDropped[];
44
64
  }
45
65
 
46
66
  // ── Persistence ─────────────────────────────────────────────────────────────
@@ -70,7 +90,10 @@ function defaultState(): PendingOMState {
70
90
  }
71
91
 
72
92
  function isEmptyState(s: PendingOMState): boolean {
73
- return !s.observation && !s.reflection && !s.dropped;
93
+ return !s.observation && !s.reflection && !s.dropped
94
+ && (!s.observationBatches || s.observationBatches.length === 0)
95
+ && (!s.reflectionBatches || s.reflectionBatches.length === 0)
96
+ && (!s.droppedBatches || s.droppedBatches.length === 0);
74
97
  }
75
98
 
76
99
  // ── Per-session file read/write ─────────────────────────────────────────────
@@ -145,6 +168,10 @@ function isPendingOMState(value: unknown): value is PendingOMState {
145
168
  export function savePendingObservation(sessionId: string, entry: PendingObservation): void {
146
169
  const state = readSessionState(sessionId);
147
170
  state.observation = entry;
171
+ // Append to accumulated batches for LLM context and /blackhole flush.
172
+ // Each batch preserves per-run coverage (coversUpToId) matching the
173
+ // normal branch-marker pattern.
174
+ state.observationBatches = [...(state.observationBatches ?? []), entry];
148
175
  writeSessionState(sessionId, state);
149
176
  }
150
177
 
@@ -154,15 +181,20 @@ export function savePendingObservation(sessionId: string, entry: PendingObservat
154
181
  export function savePendingReflection(sessionId: string, entry: PendingReflection): void {
155
182
  const state = readSessionState(sessionId);
156
183
  state.reflection = entry;
184
+ // Append to accumulated batches for LLM context and /blackhole flush.
185
+ state.reflectionBatches = [...(state.reflectionBatches ?? []), entry];
157
186
  writeSessionState(sessionId, state);
158
187
  }
159
188
 
160
189
  /**
161
- * Save (replace) the latest dropper result for a session.
190
+ * Save (replace) the latest dropper result for a session and
191
+ * append to droppedBatches so no drops are lost across cycles
192
+ * before /blackhole flush.
162
193
  */
163
194
  export function savePendingDropped(sessionId: string, entry: PendingDropped): void {
164
195
  const state = readSessionState(sessionId);
165
196
  state.dropped = entry;
197
+ state.droppedBatches = [...(state.droppedBatches ?? []), entry];
166
198
  writeSessionState(sessionId, state);
167
199
  }
168
200