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.
- package/README.md +7 -2
- package/example-config.json +14 -3
- package/package.json +12 -7
- package/src/commands/memory.ts +36 -8
- package/src/commands/pi-vcc.ts +23 -8
- package/src/core/brief.ts +33 -3
- package/src/core/load-messages.ts +5 -1
- package/src/core/settings.ts +1 -7
- package/src/core/unified-config.ts +11 -5
- package/src/hooks/before-compact.ts +11 -26
- package/src/om/compaction-trigger.ts +15 -3
- package/src/om/consolidation.ts +250 -37
- package/src/om/ledger/projection.ts +4 -1
- package/src/om/ledger/recall.ts +3 -3
- package/src/om/pending.ts +34 -2
- package/src/om/reverse-recall.ts +1 -2
- package/src/om/runtime.ts +4 -0
- package/src/core/report.ts +0 -237
- package/src/om/compaction-hook.ts +0 -63
package/src/om/consolidation.ts
CHANGED
|
@@ -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
|
-
|
|
274
|
-
|
|
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 ${
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
381
|
-
const
|
|
382
|
-
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);
|
|
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
|
-
|
|
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
|
-
|
|
557
|
+
sourceReflections,
|
|
395
558
|
Math.floor(runtime.config.reflectorInputMaxTokens * 0.15),
|
|
396
559
|
);
|
|
397
560
|
const existingObservationsSummary = buildExistingObservationsSummary(
|
|
398
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
|
465
|
-
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);
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 });
|
package/src/om/ledger/recall.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
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
|
|
package/src/om/reverse-recall.ts
CHANGED
|
@@ -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;
|