pi-blackhole 0.2.0 → 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.
@@ -18,6 +18,7 @@ import { type ResolveResult, type Runtime } from "./runtime.js";
18
18
  import { isRetryableError } from "./cooldown.js";
19
19
  import { serializeSourceAddressedBranchEntries } from "./serialize.js";
20
20
  import {
21
+ readPendingState,
21
22
  savePendingObservation,
22
23
  savePendingReflection,
23
24
  savePendingDropped,
@@ -33,6 +34,7 @@ import {
33
34
  buildObservationsRecordedData,
34
35
  buildReflectionsRecordedData,
35
36
  earlierCoverageMarkerId,
37
+ entryIndexForId,
36
38
  foldLedger,
37
39
  findLastCompactionIndex,
38
40
  fullProjection,
@@ -41,12 +43,14 @@ import {
41
43
  latestCoverageMarkerId,
42
44
  observationsCreatedAfterIndex,
43
45
  observationToSummaryLine,
46
+ rawTokensAfterIndex,
44
47
  rawTokensSinceDropCoverage,
45
48
  rawTokensSinceObservationCoverage,
46
49
  rawTokensSinceReflectionCoverage,
47
50
  reflectionToSummaryLine,
48
51
  reflectionsCreatedAfterIndex,
49
52
  type Entry,
53
+ type Observation,
50
54
  type Reflection,
51
55
  } from "./ledger/index.js";
52
56
 
@@ -121,6 +125,82 @@ function mergeReflections(existing: Reflection[], additional: Reflection[]): Ref
121
125
  return merged;
122
126
  }
123
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
+
124
204
  function anyStageDue(entries: Entry[], runtime: Runtime): boolean {
125
205
  return rawTokensSinceObservationCoverage(entries) >= runtime.config.observeAfterTokens
126
206
  || rawTokensSinceReflectionCoverage(entries) >= runtime.config.reflectAfterTokens
@@ -139,8 +219,8 @@ function stageFallbackModels(runtime: Runtime, stage: "observer" | "reflector" |
139
219
  return runtime.config.dropperFallbackModels ?? [];
140
220
  }
141
221
 
142
- function stageThinkingLevel(runtime: Runtime, stage: "observer" | "reflector" | "dropper"): ModelThinkingLevel {
143
- const stageModel = stageModelConfig(runtime, stage);
222
+ function stageThinkingLevel(runtime: Runtime, stage: "observer" | "reflector" | "dropper", modelConfig?: ConfiguredModel): ModelThinkingLevel {
223
+ const stageModel = modelConfig ?? stageModelConfig(runtime, stage);
144
224
  return stageModel?.thinking ?? runtime.config.model?.thinking ?? "low";
145
225
  }
146
226
 
@@ -269,12 +349,40 @@ async function runObserverStage(
269
349
  if (!chunk.trim() || sourceEntryIds.length === 0) return "continue";
270
350
  const chunkTokens = Math.ceil(chunk.length / 4);
271
351
 
352
+ const sessionId = ctx.sessionManager.getSessionId();
353
+
272
354
  const memory = fullProjection(entries);
273
- const priorReflections = memory.reflections.map(reflectionToSummaryLine);
274
- 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
+ }
275
384
 
276
385
  // If noAutoCompact: skip if this exact chunk was already processed
277
- const sessionId = ctx.sessionManager.getSessionId();
278
386
  if (runtime.config.noAutoCompact && isObservationChunkPending(sessionId, coversUpToId)) {
279
387
  debugLog("observer.pending_skip", { coversUpToId, sessionId });
280
388
  return "continue";
@@ -284,12 +392,23 @@ async function runObserverStage(
284
392
  const resolved = await resolveModel("observer");
285
393
  if (!resolved) return "abort";
286
394
 
395
+ // Adjust accumulated for pending coverage in noAutoCompact mode
396
+ let effectiveTokens = tokens;
397
+ if (runtime.config.noAutoCompact) {
398
+ const pending = readPendingState(sessionId);
399
+ if (pending.observation?.coversUpToId) {
400
+ const idx = entryIndexForId(entries, pending.observation.coversUpToId);
401
+ if (idx >= 0) effectiveTokens = rawTokensAfterIndex(entries, idx);
402
+ }
403
+ }
287
404
  if (ctx.hasUI) ctx.ui?.notify(
288
- `Observational memory: observer running on ~${chunkTokens.toLocaleString()}-token chunk (of ${tokens.toLocaleString()} accumulated)`,
405
+ `Observational memory: observer running on ~${chunkTokens.toLocaleString()}-token chunk (of ${effectiveTokens.toLocaleString()} accumulated)`,
289
406
  "info",
290
407
  );
291
408
  debugLog("observer.start", { tokens, coversUpToId, sourceEntryIds, sourceEntryCount: sourceEntryIds.length, priorReflections: priorReflections.length, priorObservations: priorObservations.length });
292
409
 
410
+ // Resolve thinking level for the specific model (fallbacks may have their own thinking config)
411
+ const stageModelForThinking = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "observer"), stageFallbacks: stageFallbackModels(runtime, "observer") });
293
412
  try {
294
413
  const result = await runObserver({
295
414
  model: resolved.model as any,
@@ -300,7 +419,7 @@ async function runObserverStage(
300
419
  chunk,
301
420
  allowedSourceEntryIds: sourceEntryIds,
302
421
  maxTurns: runtime.config.agentMaxTurns,
303
- thinkingLevel: stageThinkingLevel(runtime, "observer"),
422
+ thinkingLevel: stageThinkingLevel(runtime, "observer", stageModelForThinking),
304
423
  });
305
424
 
306
425
  if (result.observations && result.observations.length > 0) {
@@ -365,11 +484,33 @@ async function runReflectorStage(
365
484
  ): Promise<ReflectorStageResult> {
366
485
  const sessionId = ctx.sessionManager.getSessionId();
367
486
  const entries = ctx.sessionManager.getBranch() as Entry[];
368
- const reflectionTokens = rawTokensSinceReflectionCoverage(entries);
369
- if (reflectionTokens < runtime.config.reflectAfterTokens) return { outcome: "continue", sameRunReflections: [] };
370
-
371
- const observationCoverageId = latestCoverageMarkerId(entries, OM_OBSERVATIONS_RECORDED);
372
- 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
+ }
373
514
 
374
515
  for (let attempt = 0; attempt < MAX_STAGE_ATTEMPTS; attempt++) {
375
516
  const resolved = await resolveModel("reflector");
@@ -377,25 +518,47 @@ async function runReflectorStage(
377
518
 
378
519
  // Compute ahead for an accurate notification
379
520
  const folded = foldLedger(entries);
380
- const lastReflectionIdx = latestCoverageIndex(entries, OM_REFLECTIONS_RECORDED);
381
- const newObservations = observationsCreatedAfterIndex(entries, lastReflectionIdx);
382
- 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);
383
527
  const newItemsTokens = Math.ceil(
384
- (newObservations.reduce((s, o) => s + o.content.length, 0) +
385
- 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
386
530
  );
387
531
  const summaryBudget = Math.floor(runtime.config.reflectorInputMaxTokens * 0.15) * 2;
388
532
  const reflectorInputTokens = Math.min(newItemsTokens + summaryBudget, runtime.config.reflectorInputMaxTokens);
389
- if (ctx.hasUI) ctx.ui?.notify(`Observational memory: reflector running (~${reflectionTokens.toLocaleString()} tokens accumulated, ~${reflectorInputTokens.toLocaleString()}-token input)`, "info");
533
+ // Adjust accumulated for pending coverage in noAutoCompact mode
534
+ let effectiveReflectionTokens = reflectionTokens;
535
+ if (runtime.config.noAutoCompact) {
536
+ const pending = readPendingState(sessionId);
537
+ if (pending.reflection?.coversUpToId) {
538
+ const idx = entryIndexForId(entries, pending.reflection.coversUpToId);
539
+ if (idx >= 0) effectiveReflectionTokens = rawTokensAfterIndex(entries, idx);
540
+ }
541
+ }
542
+ if (ctx.hasUI) ctx.ui?.notify(`Observational memory: reflector running (~${effectiveReflectionTokens.toLocaleString()} tokens accumulated, ~${reflectorInputTokens.toLocaleString()}-token input)`, "info");
390
543
 
544
+ // Resolve thinking level for the specific model (fallbacks may have their own thinking config)
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") });
391
546
  try {
392
- // 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;
393
556
  const existingReflectionsSummary = buildExistingReflectionsSummary(
394
- folded.reflections,
557
+ sourceReflections,
395
558
  Math.floor(runtime.config.reflectorInputMaxTokens * 0.15),
396
559
  );
397
560
  const existingObservationsSummary = buildExistingObservationsSummary(
398
- folded.activeObservations.filter(o => !newObservations.some(no => no.id === o.id)),
561
+ sourceObservations.filter((o: any) => !newObservations.some((no: any) => no.id === o.id)),
399
562
  Math.floor(runtime.config.reflectorInputMaxTokens * 0.15),
400
563
  );
401
564
 
@@ -408,10 +571,11 @@ async function runReflectorStage(
408
571
  existingReflectionsSummary: existingReflectionsSummary || undefined,
409
572
  existingObservationsSummary: existingObservationsSummary || undefined,
410
573
  maxTurns: runtime.config.agentMaxTurns,
411
- thinkingLevel: stageThinkingLevel(runtime, "reflector"),
574
+ thinkingLevel: stageThinkingLevel(runtime, "reflector", stageModelForThinking),
412
575
  });
413
576
 
414
577
  if (!reflections || reflections.length === 0) return { outcome: "continue", sameRunReflections: [] };
578
+ if (!observationCoverageId) return { outcome: "continue", sameRunReflections: [] };
415
579
 
416
580
  const data = buildReflectionsRecordedData(reflections, observationCoverageId);
417
581
  if (!data) return { outcome: "continue", sameRunReflections: [] };
@@ -449,11 +613,33 @@ async function runDropperStage(
449
613
  ): Promise<StageOutcome> {
450
614
  const sessionId = ctx.sessionManager.getSessionId();
451
615
  const entries = ctx.sessionManager.getBranch() as Entry[];
452
- const dropTokens = rawTokensSinceDropCoverage(entries);
453
- if (dropTokens < runtime.config.reflectAfterTokens) return "continue";
454
-
455
- const observationCoverageId = latestCoverageMarkerId(entries, OM_OBSERVATIONS_RECORDED);
456
- 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
+ }
457
643
 
458
644
  for (let attempt = 0; attempt < MAX_STAGE_ATTEMPTS; attempt++) {
459
645
  const resolved = await resolveModel("dropper");
@@ -461,23 +647,48 @@ async function runDropperStage(
461
647
 
462
648
  // Compute ahead for an accurate notification
463
649
  const folded = foldLedger(entries);
464
- const lastDropIdx = latestCoverageIndex(entries, OM_OBSERVATIONS_DROPPED);
465
- 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);
466
655
  const dropperNewObsTokens = Math.ceil(
467
- newObservations.reduce((s, o) => s + o.content.length, 0) / 4
656
+ newObservations.reduce((s: number, o: any) => s + o.content.length, 0) / 4
468
657
  );
469
658
  const dropperSummaryBudget = Math.floor(runtime.config.dropperInputMaxTokens * 0.2);
470
659
  const dropperInputTokens = Math.min(dropperNewObsTokens + dropperSummaryBudget, runtime.config.dropperInputMaxTokens);
471
- if (ctx.hasUI) ctx.ui?.notify(`Observational memory: dropper running (~${dropTokens.toLocaleString()} tokens accumulated, ~${dropperInputTokens.toLocaleString()}-token input)`, "info");
660
+ // Adjust accumulated for pending coverage in noAutoCompact mode
661
+ let effectiveDropTokens = dropTokens;
662
+ if (runtime.config.noAutoCompact) {
663
+ const pending = readPendingState(sessionId);
664
+ if (pending.dropped?.coversUpToId) {
665
+ const idx = entryIndexForId(entries, pending.dropped.coversUpToId);
666
+ if (idx >= 0) effectiveDropTokens = rawTokensAfterIndex(entries, idx);
667
+ }
668
+ }
669
+ if (ctx.hasUI) ctx.ui?.notify(`Observational memory: dropper running (~${effectiveDropTokens.toLocaleString()} tokens accumulated, ~${dropperInputTokens.toLocaleString()}-token input)`, "info");
472
670
 
473
671
  try {
474
- // 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;
475
678
  const existingObservationsSummary = buildExistingObservationsSummary(
476
- folded.activeObservations.filter(o => !newObservations.some(no => no.id === o.id)),
679
+ sourceObsForDropper.filter((o: any) => !newObservations.some((no: any) => no.id === o.id)),
477
680
  Math.floor(runtime.config.dropperInputMaxTokens * 0.2),
478
681
  );
479
- const reflectionsForDropper = mergeReflections(folded.reflections, sameRunReflections);
480
-
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);
689
+
690
+ // Resolve thinking level for the specific model (fallbacks may have their own thinking config)
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") });
481
692
  const droppedIds = await runDropper({
482
693
  model: resolved.model as any,
483
694
  apiKey: resolved.apiKey,
@@ -487,9 +698,11 @@ async function runDropperStage(
487
698
  existingObservationsSummary: existingObservationsSummary || undefined,
488
699
  budgetTokens: runtime.config.observationsPoolMaxTokens,
489
700
  maxTurns: runtime.config.agentMaxTurns,
490
- thinkingLevel: stageThinkingLevel(runtime, "dropper"),
701
+ thinkingLevel: stageThinkingLevel(runtime, "dropper", stageModelForThinking),
491
702
  });
492
- const latestReflectionCoverageId = latestCoverageMarkerId(entries, OM_REFLECTIONS_RECORDED);
703
+ const latestReflectionCoverageId = runtime.config.noAutoCompact
704
+ ? pending?.reflection?.coversUpToId
705
+ : latestCoverageMarkerId(entries, OM_REFLECTIONS_RECORDED);
493
706
  const effectiveReflectionCoverageId = sameRunReflectionCoverageId ?? latestReflectionCoverageId;
494
707
  const coversUpToId = earlierCoverageMarkerId(entries, observationCoverageId, effectiveReflectionCoverageId);
495
708
  const data = coversUpToId && droppedIds ? buildObservationsDroppedData(droppedIds, coversUpToId) : undefined;
@@ -157,7 +157,10 @@ export function fullProjection(entries: Entry[], upToEntryId?: string): Projecti
157
157
  export function visibleProjection(entries: Entry[], upToEntryId?: string): Projection {
158
158
  if (!upToEntryId) {
159
159
  const details = latestV3CompactionDetails(entries);
160
- return details ? projectionFromMemoryDetails(details) : { observations: [], reflections: [] };
160
+ if (details) return projectionFromMemoryDetails(details);
161
+ // No compaction has run yet — show everything so the user sees
162
+ // recorded data until first /blackhole creates a proper snapshot.
163
+ return fullProjection(entries);
161
164
  }
162
165
 
163
166
  return buildCompactionProjection(entries, upToEntryId, { observationsPoolMaxTokens: Number.POSITIVE_INFINITY });
@@ -188,7 +188,7 @@ export function recallMemorySources(entries: Entry[], memoryId: string): RecallR
188
188
  }
189
189
 
190
190
  const recalledByKey = new Map<string, RecalledObservation>();
191
- const missingSupportingObservationIds: string[] = [];
191
+ const rawMissingSupportingObservationIds: string[] = [];
192
192
 
193
193
  function addObservation(indexed: IndexedObservation): void {
194
194
  const key = `${indexed.entryId}:${indexed.recordIndex}`;
@@ -204,7 +204,7 @@ export function recallMemorySources(entries: Entry[], memoryId: string): RecallR
204
204
  for (const observationId of uniqueStrings(reflection.supportingObservationIds)) {
205
205
  const indexed = observationsById.get(observationId);
206
206
  if (!indexed) {
207
- missingSupportingObservationIds.push(observationId);
207
+ rawMissingSupportingObservationIds.push(observationId);
208
208
  continue;
209
209
  }
210
210
  addObservation(indexed);
@@ -220,7 +220,7 @@ export function recallMemorySources(entries: Entry[], memoryId: string): RecallR
220
220
  const sourceEntries = uniqueById(recalledObservations.flatMap((match) => match.sourceEntries));
221
221
  const missingSourceEntryIds = uniqueStrings(recalledObservations.flatMap((match) => match.missingSourceEntryIds));
222
222
  const nonSourceEntryIds = uniqueStrings(recalledObservations.flatMap((match) => match.nonSourceEntryIds));
223
- const uniqueMissingSupportingObservationIds = uniqueStrings(missingSupportingObservationIds);
223
+ const uniqueMissingSupportingObservationIds = uniqueStrings(rawMissingSupportingObservationIds);
224
224
  const matchCount = directObservationMatches.length + reflectionMatches.length;
225
225
 
226
226
  return {
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
 
@@ -58,11 +58,10 @@ export function findReflectionsForEntryIds(
58
58
  targetEntryIds: string[],
59
59
  ): RelatedReflection[] {
60
60
  if (targetEntryIds.length === 0) return [];
61
- const { reflections } = indexLedger(entries);
61
+ const { reflections, observations } = indexLedger(entries);
62
62
  // Reflections don't directly reference entry IDs — they reference observation IDs.
63
63
  // So we only match indirectly: first find observations for these entry IDs,
64
64
  // then find reflections that support those observations.
65
- const { observations } = indexLedger(entries);
66
65
  const targetSet = new Set(targetEntryIds);
67
66
  const matchingObsIds = new Set<string>();
68
67
  for (const indexed of observations) {
package/src/om/runtime.ts CHANGED
@@ -53,6 +53,10 @@ export class Runtime {
53
53
  lastDropperError: string | undefined;
54
54
  /** Epoch ms of the last failed consolidation run (any stage). */
55
55
  lastConsolidationErrorAt: number | undefined;
56
+ /** Stats from the most recent compaction run (session-scoped via handler closure). */
57
+ compactionStats: { summarized: number; kept: number; keptTokensEst: number } | null = null;
58
+ /** Whether the most recent compaction was triggered by /blackhole (vs auto-compact). */
59
+ compactWasPiVcc = false;
56
60
 
57
61
  ensureConfig(cwd: string): void {
58
62
  if (this.configLoaded) return;