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 +5 -0
- package/example-config.json +14 -3
- package/package.json +1 -1
- package/src/commands/memory.ts +13 -5
- package/src/commands/pi-vcc.ts +21 -6
- package/src/core/unified-config.ts +9 -3
- package/src/om/consolidation.ts +205 -28
- package/src/om/pending.ts +34 -2
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
|
package/example-config.json
CHANGED
|
@@ -86,13 +86,14 @@
|
|
|
86
86
|
}
|
|
87
87
|
],
|
|
88
88
|
|
|
89
|
-
"observeAfterTokens":
|
|
90
|
-
"reflectAfterTokens":
|
|
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.
|
|
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",
|
package/src/commands/memory.ts
CHANGED
|
@@ -164,11 +164,12 @@ export function registerMemoryCommand(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
164
164
|
observationLine,
|
|
165
165
|
reflectionLine,
|
|
166
166
|
"",
|
|
167
|
-
"──
|
|
168
|
-
|
|
169
|
-
`
|
|
170
|
-
`
|
|
171
|
-
`
|
|
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
|
}
|
package/src/commands/pi-vcc.ts
CHANGED
|
@@ -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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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:
|
|
91
|
-
reflectAfterTokens:
|
|
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;
|
package/src/om/consolidation.ts
CHANGED
|
@@ -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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
|
395
|
-
const
|
|
396
|
-
const
|
|
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
|
-
|
|
557
|
+
sourceReflections,
|
|
420
558
|
Math.floor(runtime.config.reflectorInputMaxTokens * 0.15),
|
|
421
559
|
);
|
|
422
560
|
const existingObservationsSummary = buildExistingObservationsSummary(
|
|
423
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
|
490
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|