pi-blackhole 0.2.2 → 0.3.0
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 +23 -8
- package/index.ts +45 -0
- package/package.json +12 -5
- package/src/commands/pi-vcc.ts +18 -8
- package/src/commands/vcc-recall.ts +3 -2
- package/src/core/build-sections.ts +1 -1
- package/src/core/content.ts +70 -0
- package/src/core/drill-down.ts +273 -0
- package/src/core/format-recall.ts +107 -3
- package/src/core/load-messages.ts +3 -2
- package/src/core/recall-scope.ts +15 -4
- package/src/core/search-entries.ts +155 -10
- package/src/core/unified-config.ts +48 -17
- package/src/om/agents/dropper/agent.ts +144 -12
- package/src/om/agents/dropper/coverage.ts +128 -0
- package/src/om/agents/dropper/prompts.ts +12 -12
- package/src/om/agents/observer/agent.ts +22 -2
- package/src/om/agents/observer/prompts.ts +1 -1
- package/src/om/agents/reflector/agent.ts +51 -2
- package/src/om/agents/reflector/prompts.ts +8 -4
- package/src/om/consolidation.ts +1 -47
- package/src/om/cooldown.ts +11 -4
- package/src/om/ledger/projection.ts +13 -1
- package/src/om/ledger/render-summary.ts +51 -0
- package/src/om/pending.ts +8 -4
- package/src/tools/recall.ts +120 -44
- package/vitest.config.ts +1 -23
|
@@ -7,11 +7,21 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
9
9
|
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
10
|
-
import {
|
|
10
|
+
import { streamSimple } from "@earendil-works/pi-ai";
|
|
11
|
+
import { Type } from "typebox";
|
|
11
12
|
import type { Static } from "typebox";
|
|
13
|
+
import { debugLog } from "../../debug-log.js";
|
|
12
14
|
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
|
|
13
|
-
import {
|
|
15
|
+
import { reflectionToSummaryLine, type Observation, type Reflection } from "../../ledger/index.js";
|
|
14
16
|
import { DROPPER_SYSTEM } from "./prompts.js";
|
|
17
|
+
import {
|
|
18
|
+
REFLECTION_COVERAGE_DROP_RANK,
|
|
19
|
+
coverageTierForObservation,
|
|
20
|
+
observationToDropperLine,
|
|
21
|
+
reflectionCoverageMap,
|
|
22
|
+
summarizeCoverageByRelevance,
|
|
23
|
+
summarizeCoverageByRelevanceForIds,
|
|
24
|
+
} from "./coverage.js";
|
|
15
25
|
|
|
16
26
|
interface RunDropperArgs {
|
|
17
27
|
model: Model<any>;
|
|
@@ -24,6 +34,10 @@ interface RunDropperArgs {
|
|
|
24
34
|
budgetTokens: number;
|
|
25
35
|
signal?: AbortSignal;
|
|
26
36
|
agentLoop?: typeof agentLoop;
|
|
37
|
+
/** Optional custom stream function bypassing agentLoop's default streamSimple.
|
|
38
|
+
* Used by the Symbol.for bridge to access native pi-ai provider registrations
|
|
39
|
+
* from jiti-loaded consolidation agents. */
|
|
40
|
+
streamFn?: (model: any, context: any, options: any) => any;
|
|
27
41
|
maxTurns?: number;
|
|
28
42
|
thinkingLevel?: ModelThinkingLevel;
|
|
29
43
|
}
|
|
@@ -81,6 +95,13 @@ export function maxDropCountForPool(observations: readonly Observation[], observ
|
|
|
81
95
|
return Math.max(1, Math.floor(droppableCount * dropRatio));
|
|
82
96
|
}
|
|
83
97
|
|
|
98
|
+
function relevanceCounts(observations: readonly Observation[]): Record<Observation["relevance"], number> {
|
|
99
|
+
return observations.reduce<Record<Observation["relevance"], number>>((counts, observation) => {
|
|
100
|
+
if (observation.relevance in counts) counts[observation.relevance]++;
|
|
101
|
+
return counts;
|
|
102
|
+
}, { low: 0, medium: 0, high: 0, critical: 0 });
|
|
103
|
+
}
|
|
104
|
+
|
|
84
105
|
export function normalizeDropObservationIds(
|
|
85
106
|
ids: readonly string[] | undefined,
|
|
86
107
|
observations: readonly Observation[],
|
|
@@ -92,7 +113,6 @@ export function normalizeDropObservationIds(
|
|
|
92
113
|
for (const id of ids) {
|
|
93
114
|
const observation = allowed.get(id);
|
|
94
115
|
if (!observation) continue;
|
|
95
|
-
if (observation.relevance === "critical") continue;
|
|
96
116
|
if (seen.has(id)) continue;
|
|
97
117
|
seen.add(id);
|
|
98
118
|
result.push(id);
|
|
@@ -100,14 +120,21 @@ export function normalizeDropObservationIds(
|
|
|
100
120
|
return result.length > 0 ? result : undefined;
|
|
101
121
|
}
|
|
102
122
|
|
|
123
|
+
function timestampRank(timestamp: string): number {
|
|
124
|
+
const parsed = Date.parse(timestamp);
|
|
125
|
+
return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY;
|
|
126
|
+
}
|
|
127
|
+
|
|
103
128
|
export function selectDropCandidates(
|
|
104
129
|
ids: readonly string[],
|
|
105
130
|
observations: readonly Observation[],
|
|
106
131
|
maxDrops: number,
|
|
132
|
+
reflections: readonly Reflection[] = [],
|
|
107
133
|
): string[] {
|
|
108
134
|
if (maxDrops <= 0 || ids.length === 0) return [];
|
|
109
135
|
|
|
110
136
|
const byId = new Map(observations.map((observation) => [observation.id, observation]));
|
|
137
|
+
const coverageById = reflectionCoverageMap(observations, reflections);
|
|
111
138
|
const firstProposalIndex = new Map<string, number>();
|
|
112
139
|
for (let i = 0; i < ids.length; i++) {
|
|
113
140
|
const id = ids[i];
|
|
@@ -117,11 +144,16 @@ export function selectDropCandidates(
|
|
|
117
144
|
return Array.from(firstProposalIndex.entries())
|
|
118
145
|
.map(([id, index]) => ({ id, index, observation: byId.get(id) }))
|
|
119
146
|
.filter((candidate): candidate is { id: string; index: number; observation: Observation } =>
|
|
120
|
-
candidate.observation !== undefined
|
|
147
|
+
candidate.observation !== undefined
|
|
121
148
|
)
|
|
122
149
|
.sort((a, b) => {
|
|
150
|
+
const coverageDelta = REFLECTION_COVERAGE_DROP_RANK[coverageTierForObservation(a.observation, coverageById)]
|
|
151
|
+
- REFLECTION_COVERAGE_DROP_RANK[coverageTierForObservation(b.observation, coverageById)];
|
|
123
152
|
const relevanceDelta = RELEVANCE_DROP_RANK[a.observation.relevance] - RELEVANCE_DROP_RANK[b.observation.relevance];
|
|
124
|
-
|
|
153
|
+
const aAge = timestampRank(a.observation.timestamp);
|
|
154
|
+
const bAge = timestampRank(b.observation.timestamp);
|
|
155
|
+
const ageDelta = aAge === bAge ? 0 : aAge - bAge;
|
|
156
|
+
return coverageDelta || relevanceDelta || ageDelta || a.index - b.index;
|
|
125
157
|
})
|
|
126
158
|
.slice(0, maxDrops)
|
|
127
159
|
.map((candidate) => candidate.id);
|
|
@@ -135,10 +167,42 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
|
|
|
135
167
|
const fullness = observationPoolFullness(observationTokens, budgetTokens);
|
|
136
168
|
const urgency = dropUrgencyForFullness(fullness);
|
|
137
169
|
const maxDropsAllowed = maxDropCountForPool(observations, observationTokens, budgetTokens);
|
|
138
|
-
|
|
170
|
+
const coverageById = reflectionCoverageMap(observations, reflections);
|
|
171
|
+
const coverageSummaryByRelevance = summarizeCoverageByRelevance(observations, coverageById);
|
|
172
|
+
debugLog("dropper.agent_start", {
|
|
173
|
+
activeObservationCount: observations.length,
|
|
174
|
+
reflectionCount: reflections.length,
|
|
175
|
+
observationTokens,
|
|
176
|
+
budgetTokens,
|
|
177
|
+
fullness,
|
|
178
|
+
urgency,
|
|
179
|
+
maxDropsAllowed,
|
|
180
|
+
relevanceCounts: relevanceCounts(observations),
|
|
181
|
+
coverageSummaryByRelevance,
|
|
182
|
+
});
|
|
183
|
+
if (maxDropsAllowed <= 0) {
|
|
184
|
+
debugLog("dropper.result", {
|
|
185
|
+
reason: "not_over_target",
|
|
186
|
+
toolCallCount: 0,
|
|
187
|
+
rawRequestedIdsCount: 0,
|
|
188
|
+
acceptedCandidateCount: 0,
|
|
189
|
+
selectedDropsCount: 0,
|
|
190
|
+
selectedDropTokens: 0,
|
|
191
|
+
selectedCoverageSummaryByRelevance: summarizeCoverageByRelevanceForIds([], observations, coverageById),
|
|
192
|
+
maxDropsAllowed,
|
|
193
|
+
});
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
139
196
|
|
|
140
197
|
const proposedDropIds: string[] = [];
|
|
141
198
|
const proposed = new Set<string>();
|
|
199
|
+
const allowed = new Map(observations.map((observation) => [observation.id, observation]));
|
|
200
|
+
let toolCallCount = 0;
|
|
201
|
+
let rawRequestedIdsCount = 0;
|
|
202
|
+
let missingIdsCount = 0;
|
|
203
|
+
let criticalCandidateIdsCount = 0;
|
|
204
|
+
let duplicateInRequestCount = 0;
|
|
205
|
+
let duplicateInRunCount = 0;
|
|
142
206
|
|
|
143
207
|
const dropObservations: AgentTool<typeof DropObservationsSchema> = {
|
|
144
208
|
name: "drop_observations",
|
|
@@ -146,14 +210,51 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
|
|
|
146
210
|
description: "Propose active observation ids that are safe to remove from compacted memory.",
|
|
147
211
|
parameters: DropObservationsSchema,
|
|
148
212
|
execute: async (_id, params: DropObservationsArgs) => {
|
|
149
|
-
|
|
213
|
+
toolCallCount++;
|
|
214
|
+
rawRequestedIdsCount += params.ids.length;
|
|
215
|
+
const seenInRequest = new Set<string>();
|
|
150
216
|
let added = 0;
|
|
151
|
-
|
|
152
|
-
|
|
217
|
+
let requestMissingIds = 0;
|
|
218
|
+
let requestCriticalCandidateIds = 0;
|
|
219
|
+
let requestDuplicateIds = 0;
|
|
220
|
+
let requestDuplicateInRunIds = 0;
|
|
221
|
+
for (const id of params.ids) {
|
|
222
|
+
const observation = allowed.get(id);
|
|
223
|
+
if (!observation) {
|
|
224
|
+
missingIdsCount++;
|
|
225
|
+
requestMissingIds++;
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (seenInRequest.has(id)) {
|
|
229
|
+
duplicateInRequestCount++;
|
|
230
|
+
requestDuplicateIds++;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
seenInRequest.add(id);
|
|
234
|
+
if (proposed.has(id)) {
|
|
235
|
+
duplicateInRunCount++;
|
|
236
|
+
requestDuplicateInRunIds++;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
153
239
|
proposed.add(id);
|
|
154
240
|
proposedDropIds.push(id);
|
|
241
|
+
if (observation.relevance === "critical") {
|
|
242
|
+
criticalCandidateIdsCount++;
|
|
243
|
+
requestCriticalCandidateIds++;
|
|
244
|
+
}
|
|
155
245
|
added++;
|
|
156
246
|
}
|
|
247
|
+
debugLog("dropper.tool_call", {
|
|
248
|
+
toolCallCount,
|
|
249
|
+
rawRequestedIdsCount: params.ids.length,
|
|
250
|
+
acceptedIdsCount: added,
|
|
251
|
+
missingIdsCount: requestMissingIds,
|
|
252
|
+
criticalCandidateIdsCount: requestCriticalCandidateIds,
|
|
253
|
+
duplicateInRequestCount: requestDuplicateIds,
|
|
254
|
+
duplicateInRunCount: requestDuplicateInRunIds,
|
|
255
|
+
totalCandidates: proposedDropIds.length,
|
|
256
|
+
maxDropsAllowed,
|
|
257
|
+
});
|
|
157
258
|
return {
|
|
158
259
|
content: [{ type: "text", text: `Queued ${added} drop candidate${added === 1 ? "" : "s"}. Candidates this run: ${proposedDropIds.length}. Maximum drops allowed: ${maxDropsAllowed}.` }],
|
|
159
260
|
details: { added, totalCandidates: proposedDropIds.length, maxDropsAllowed },
|
|
@@ -166,7 +267,7 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
|
|
|
166
267
|
? `EXISTING ACTIVE OBSERVATIONS (for context only — these are NOT candidates for dropping):\n${args.existingObservationsSummary}\n\n`
|
|
167
268
|
: '';
|
|
168
269
|
|
|
169
|
-
const userText = `CURRENT REFLECTIONS:\n${joinOrEmpty(reflections.map(reflectionToSummaryLine))}\n\n${existingObservationsContext}NEW OBSERVATIONS TO EVALUATE FOR DROPPING:\n${joinOrEmpty(observations.map(
|
|
270
|
+
const userText = `CURRENT REFLECTIONS:\n${joinOrEmpty(reflections.map(reflectionToSummaryLine))}\n\n${existingObservationsContext}NEW OBSERVATIONS TO EVALUATE FOR DROPPING:\n${joinOrEmpty(observations.map((observation) => observationToDropperLine(observation, coverageTierForObservation(observation, coverageById))))}\n\nObservation pool pressure: ~${observationTokens.toLocaleString()} tokens; target budget: ~${budgetTokens.toLocaleString()} tokens; fullness: ~${fullnessPercent.toLocaleString()}%.\nDrop urgency: ${urgency}.\nMaximum drops allowed this run: ${maxDropsAllowed.toLocaleString()} observation${maxDropsAllowed === 1 ? "" : "s"}.\nThis maximum is a hard upper bound, not a target. Drop fewer or none if fewer observations are clearly safe.`;
|
|
170
271
|
const prompts: Message[] = [{ role: "user", content: [{ type: "text", text: userText }], timestamp: Date.now() }];
|
|
171
272
|
const context: AgentContext = { systemPrompt: DROPPER_SYSTEM, messages: [], tools: [dropObservations as AgentTool<any>] };
|
|
172
273
|
const reasoning = (model as { reasoning?: unknown }).reasoning;
|
|
@@ -185,7 +286,16 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
|
|
|
185
286
|
};
|
|
186
287
|
|
|
187
288
|
const loop = args.agentLoop ?? agentLoop;
|
|
188
|
-
|
|
289
|
+
// ── Bridge stream function ──
|
|
290
|
+
const PROVIDER_STREAMS_KEY = Symbol.for("pi-blackhole:provider-streams");
|
|
291
|
+
const bridgeStreamFn = (model: any, ctx: any, opts: any) => {
|
|
292
|
+
const providerStreams: Map<string, Function> | undefined = (globalThis as any)[PROVIDER_STREAMS_KEY];
|
|
293
|
+
if (!providerStreams) return streamSimple(model, ctx, opts);
|
|
294
|
+
const customFn = model?.api ? providerStreams.get(model.api) : undefined;
|
|
295
|
+
return customFn ? customFn(model, ctx, opts) : streamSimple(model, ctx, opts);
|
|
296
|
+
};
|
|
297
|
+
const streamFn = args.streamFn ?? bridgeStreamFn;
|
|
298
|
+
const stream = loop(prompts, context, config, signal, streamFn);
|
|
189
299
|
let agentError: string | undefined;
|
|
190
300
|
for await (const event of stream) {
|
|
191
301
|
// Tool execution collects candidate ids.
|
|
@@ -199,6 +309,28 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
|
|
|
199
309
|
}
|
|
200
310
|
await stream.result();
|
|
201
311
|
if (agentError && proposedDropIds.length === 0) throw new Error(`Dropper API error: ${agentError}`);
|
|
202
|
-
const droppedIds = selectDropCandidates(proposedDropIds, observations, maxDropsAllowed);
|
|
312
|
+
const droppedIds = selectDropCandidates(proposedDropIds, observations, maxDropsAllowed, reflections);
|
|
313
|
+
const reason = droppedIds.length > 0
|
|
314
|
+
? "selected_nonempty"
|
|
315
|
+
: toolCallCount === 0
|
|
316
|
+
? "no_tool_call"
|
|
317
|
+
: proposedDropIds.length === 0
|
|
318
|
+
? "all_filtered"
|
|
319
|
+
: "selected_empty";
|
|
320
|
+
const selectedDropTokens = droppedIds.reduce((sum, id) => sum + (allowed.get(id)?.tokenCount ?? 0), 0);
|
|
321
|
+
debugLog("dropper.result", {
|
|
322
|
+
reason,
|
|
323
|
+
toolCallCount,
|
|
324
|
+
rawRequestedIdsCount,
|
|
325
|
+
missingIdsCount,
|
|
326
|
+
criticalCandidateIdsCount,
|
|
327
|
+
duplicateInRequestCount,
|
|
328
|
+
duplicateInRunCount,
|
|
329
|
+
acceptedCandidateCount: proposedDropIds.length,
|
|
330
|
+
selectedDropsCount: droppedIds.length,
|
|
331
|
+
selectedDropTokens,
|
|
332
|
+
selectedCoverageSummaryByRelevance: summarizeCoverageByRelevanceForIds(droppedIds, observations, coverageById),
|
|
333
|
+
maxDropsAllowed,
|
|
334
|
+
});
|
|
203
335
|
return droppedIds.length > 0 ? droppedIds : undefined;
|
|
204
336
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Observation, Reflection } from "../../ledger/index.js";
|
|
2
|
+
|
|
3
|
+
export const REFLECTION_COVERAGE_TIERS = ["none", "partial", "strong"] as const;
|
|
4
|
+
export type ReflectionCoverageTier = typeof REFLECTION_COVERAGE_TIERS[number];
|
|
5
|
+
|
|
6
|
+
type Relevance = Observation["relevance"];
|
|
7
|
+
|
|
8
|
+
type CoverageBucket = Record<ReflectionCoverageTier, { count: number; tokens: number }>;
|
|
9
|
+
export type CoverageSummaryByRelevance = Record<Relevance, CoverageBucket>;
|
|
10
|
+
export type CoverageTransitionSummaryByRelevance = Record<Relevance, Record<string, { count: number; tokens: number }>>;
|
|
11
|
+
|
|
12
|
+
export const REFLECTION_COVERAGE_DROP_RANK: Record<ReflectionCoverageTier, number> = {
|
|
13
|
+
strong: 0,
|
|
14
|
+
partial: 1,
|
|
15
|
+
none: 2,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function reflectionSupportCounts(reflections: readonly Reflection[]): Map<string, number> {
|
|
19
|
+
const counts = new Map<string, number>();
|
|
20
|
+
for (const reflection of reflections) {
|
|
21
|
+
const uniqueIds = new Set(reflection.supportingObservationIds);
|
|
22
|
+
for (const id of uniqueIds) counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
23
|
+
}
|
|
24
|
+
return counts;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function reflectionCoverageTierForCount(count: number): ReflectionCoverageTier {
|
|
28
|
+
if (count <= 0) return "none";
|
|
29
|
+
if (count === 1) return "partial";
|
|
30
|
+
return "strong";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function reflectionCoverageMap(
|
|
34
|
+
observations: readonly Observation[],
|
|
35
|
+
reflections: readonly Reflection[],
|
|
36
|
+
): Map<string, ReflectionCoverageTier> {
|
|
37
|
+
const counts = reflectionSupportCounts(reflections);
|
|
38
|
+
return new Map(observations.map((observation) => [
|
|
39
|
+
observation.id,
|
|
40
|
+
reflectionCoverageTierForCount(counts.get(observation.id) ?? 0),
|
|
41
|
+
]));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function emptyCoverageBucket(): CoverageBucket {
|
|
45
|
+
return {
|
|
46
|
+
none: { count: 0, tokens: 0 },
|
|
47
|
+
partial: { count: 0, tokens: 0 },
|
|
48
|
+
strong: { count: 0, tokens: 0 },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function emptyCoverageSummaryByRelevance(): CoverageSummaryByRelevance {
|
|
53
|
+
return {
|
|
54
|
+
low: emptyCoverageBucket(),
|
|
55
|
+
medium: emptyCoverageBucket(),
|
|
56
|
+
high: emptyCoverageBucket(),
|
|
57
|
+
critical: emptyCoverageBucket(),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function summarizeCoverageByRelevance(
|
|
62
|
+
observations: readonly Observation[],
|
|
63
|
+
coverageById: ReadonlyMap<string, ReflectionCoverageTier>,
|
|
64
|
+
): CoverageSummaryByRelevance {
|
|
65
|
+
const summary = emptyCoverageSummaryByRelevance();
|
|
66
|
+
for (const observation of observations) {
|
|
67
|
+
const tier = coverageById.get(observation.id) ?? "none";
|
|
68
|
+
const bucket = summary[observation.relevance][tier];
|
|
69
|
+
bucket.count++;
|
|
70
|
+
bucket.tokens += observation.tokenCount;
|
|
71
|
+
}
|
|
72
|
+
return summary;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function summarizeCoverageByRelevanceForIds(
|
|
76
|
+
ids: readonly string[],
|
|
77
|
+
observations: readonly Observation[],
|
|
78
|
+
coverageById: ReadonlyMap<string, ReflectionCoverageTier>,
|
|
79
|
+
): CoverageSummaryByRelevance {
|
|
80
|
+
const byId = new Map(observations.map((observation) => [observation.id, observation]));
|
|
81
|
+
const selected = ids.flatMap((id) => {
|
|
82
|
+
const observation = byId.get(id);
|
|
83
|
+
return observation ? [observation] : [];
|
|
84
|
+
});
|
|
85
|
+
return summarizeCoverageByRelevance(selected, coverageById);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function emptyCoverageTransitionSummaryByRelevance(): CoverageTransitionSummaryByRelevance {
|
|
89
|
+
return {
|
|
90
|
+
low: {},
|
|
91
|
+
medium: {},
|
|
92
|
+
high: {},
|
|
93
|
+
critical: {},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function summarizeCoverageTransitionsByRelevance(
|
|
98
|
+
observations: readonly Observation[],
|
|
99
|
+
beforeCoverageById: ReadonlyMap<string, ReflectionCoverageTier>,
|
|
100
|
+
afterCoverageById: ReadonlyMap<string, ReflectionCoverageTier>,
|
|
101
|
+
): CoverageTransitionSummaryByRelevance {
|
|
102
|
+
const summary = emptyCoverageTransitionSummaryByRelevance();
|
|
103
|
+
for (const observation of observations) {
|
|
104
|
+
const before = beforeCoverageById.get(observation.id) ?? "none";
|
|
105
|
+
const after = afterCoverageById.get(observation.id) ?? "none";
|
|
106
|
+
if (before === after) continue;
|
|
107
|
+
const key = `${before}->${after}`;
|
|
108
|
+
const bucket = summary[observation.relevance][key] ?? { count: 0, tokens: 0 };
|
|
109
|
+
bucket.count++;
|
|
110
|
+
bucket.tokens += observation.tokenCount;
|
|
111
|
+
summary[observation.relevance][key] = bucket;
|
|
112
|
+
}
|
|
113
|
+
return summary;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function observationToDropperLine(
|
|
117
|
+
observation: Observation,
|
|
118
|
+
coverage: ReflectionCoverageTier,
|
|
119
|
+
): string {
|
|
120
|
+
return `[${observation.id}] ${observation.timestamp} [${observation.relevance}] [coverage: ${coverage}] ${observation.content}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function coverageTierForObservation(
|
|
124
|
+
observation: Observation,
|
|
125
|
+
coverageById: ReadonlyMap<string, ReflectionCoverageTier>,
|
|
126
|
+
): ReflectionCoverageTier {
|
|
127
|
+
return coverageById.get(observation.id) ?? "none";
|
|
128
|
+
}
|
|
@@ -6,30 +6,30 @@ Your job is to identify only the safest active observations to remove from compa
|
|
|
6
6
|
|
|
7
7
|
Active-memory framing. Dropping an observation removes it from active compacted memory; it does not erase the ledger history or source evidence. Still, future compressed context will no longer show the observation, so only drop it when its durable meaning is safely captured elsewhere or it is genuinely low-signal and carries no unique future value.
|
|
8
8
|
|
|
9
|
-
The user message includes
|
|
10
|
-
|
|
11
|
-
Urgency guidance:
|
|
12
|
-
- low urgency: only propose trivially safe drops, usually low-signal observations with no unique detail.
|
|
13
|
-
- medium urgency: perform conservative cleanup; prefer low observations and clearly redundant medium observations.
|
|
14
|
-
- high urgency: cleanup is more useful, but preservation rules do not weaken and load-bearing memory must still be kept.
|
|
9
|
+
The user message includes the active observation pool target and "Maximum drops allowed this run". The maximum is a hard upper bound sized to move the pool toward the target if every proposed drop is clearly safe. It is not a target. Do not try to fill it. Drop fewer or none when fewer observations are safely removable. When the active pool is far over target, make a thorough pass over safe candidates rather than stopping after a few obvious examples.
|
|
15
10
|
|
|
16
11
|
What to drop, in priority order:
|
|
17
12
|
- Redundant observations whose durable meaning is already captured by current reflections with equivalent fidelity.
|
|
18
13
|
- Superseded observations where a later observation clearly replaces the older state.
|
|
19
14
|
- Repeated routine tool acknowledgements or low-signal progress updates that do not carry decisions, constraints, exact errors, or user-specific facts.
|
|
20
|
-
- Older
|
|
15
|
+
- Older observations that no longer carry working context and are covered by a reflection or a newer observation.
|
|
16
|
+
|
|
17
|
+
Age-gradient rule. Recent observations carry working context the assistant may still need; older observations have usually been summarized elsewhere or are no longer load-bearing. Prefer older safe drops before newer working context, but age alone is not enough to drop important or uniquely load-bearing observations.
|
|
21
18
|
|
|
22
|
-
|
|
19
|
+
Reflection coverage guidance. Each observation line includes [coverage: none|partial|strong]. Coverage is evidence, not an automatic decision:
|
|
20
|
+
- none: no current reflection cites this observation id. Be cautious, especially for high or critical observations.
|
|
21
|
+
- partial: one current reflection cites this observation id. Compare the observation to the reflection before dropping.
|
|
22
|
+
- strong: two or more current reflections cite this observation id. This is stronger evidence that the durable meaning is preserved, but you must still keep uniquely load-bearing or uncertain observations.
|
|
23
23
|
|
|
24
|
-
Relevance guidance:
|
|
24
|
+
Relevance guidance. Relevance is importance/resistance, not an absolute keep/drop lock:
|
|
25
25
|
- low: consider first, but drop only when it carries no unique detail, decision, state, error, identifier, or user-specific fact.
|
|
26
26
|
- medium: drop when redundant with reflections or other observations, or when the work state is clearly obsolete.
|
|
27
27
|
- high: drop only when clearly superseded or already captured by a reflection with equivalent fidelity.
|
|
28
|
-
- critical:
|
|
28
|
+
- critical: highest importance and strongest resistance. Do not drop fresh or uniquely load-bearing critical observations. Critical observations may be dropped only with strong semantic evidence such as age plus partial/strong reflection coverage, supersession by newer memory, redundancy, or clear obsolescence.
|
|
29
29
|
|
|
30
|
-
User assertions and concrete completions
|
|
30
|
+
User assertions and concrete completions must be preserved unless a current reflection or newer observation preserves the exact assertion/completion and its important details with equivalent fidelity.
|
|
31
31
|
|
|
32
|
-
Preservation floor. Regardless of relevance label,
|
|
32
|
+
Preservation floor. Regardless of relevance label, budget pressure, coverage, or age, do not drop observations that uniquely carry any of the following:
|
|
33
33
|
- User preferences, constraints, corrections, or identity/role facts.
|
|
34
34
|
- Concrete completions that future runs must not redo.
|
|
35
35
|
- Named identifiers, file paths, function names, package names, tickets, commit SHAs, handles, or exact commands.
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
10
10
|
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
11
|
-
import {
|
|
11
|
+
import { streamSimple } from "@earendil-works/pi-ai";
|
|
12
|
+
import { Type } from "typebox";
|
|
12
13
|
import type { Static } from "typebox";
|
|
13
14
|
import { hashId } from "../../ids.js";
|
|
14
15
|
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
|
|
@@ -27,6 +28,10 @@ interface RunObserverArgs {
|
|
|
27
28
|
allowedSourceEntryIds: string[];
|
|
28
29
|
signal?: AbortSignal;
|
|
29
30
|
agentLoop?: typeof agentLoop;
|
|
31
|
+
/** Optional custom stream function bypassing agentLoop's default streamSimple.
|
|
32
|
+
* Used by the Symbol.for bridge to access native pi-ai provider registrations
|
|
33
|
+
* from jiti-loaded consolidation agents. */
|
|
34
|
+
streamFn?: (model: any, context: any, options: any) => any;
|
|
30
35
|
maxTurns?: number;
|
|
31
36
|
thinkingLevel?: ModelThinkingLevel;
|
|
32
37
|
}
|
|
@@ -217,7 +222,22 @@ ${conversation}`;
|
|
|
217
222
|
};
|
|
218
223
|
|
|
219
224
|
const loop = args.agentLoop ?? agentLoop;
|
|
220
|
-
|
|
225
|
+
// ── Bridge stream function ──
|
|
226
|
+
// Consolidation agents run via jiti (moduleCache: false) which creates a separate
|
|
227
|
+
// pi-ai instance whose apiProviderRegistry lacks custom providers registered by
|
|
228
|
+
// other extensions (e.g., claude-bridge). The bridge looks up streamSimple functions
|
|
229
|
+
// from a Symbol.for() global that index.ts populates during provider registration.
|
|
230
|
+
// If no custom provider is found, it falls back to the jiti-loaded streamSimple
|
|
231
|
+
// which handles all built-in providers correctly.
|
|
232
|
+
const PROVIDER_STREAMS_KEY = Symbol.for("pi-blackhole:provider-streams");
|
|
233
|
+
const bridgeStreamFn = (model: any, ctx: any, opts: any) => {
|
|
234
|
+
const providerStreams: Map<string, Function> | undefined = (globalThis as any)[PROVIDER_STREAMS_KEY];
|
|
235
|
+
if (!providerStreams) return streamSimple(model, ctx, opts);
|
|
236
|
+
const customFn = model?.api ? providerStreams.get(model.api) : undefined;
|
|
237
|
+
return customFn ? customFn(model, ctx, opts) : streamSimple(model, ctx, opts);
|
|
238
|
+
};
|
|
239
|
+
const streamFn = args.streamFn ?? bridgeStreamFn;
|
|
240
|
+
const stream = loop(prompts, context, config, signal, streamFn);
|
|
221
241
|
let agentError: string | undefined;
|
|
222
242
|
for await (const event of stream) {
|
|
223
243
|
// Drain events; the tool's execute already collects records.
|
|
@@ -101,7 +101,7 @@ If a detail is non-obvious from the code or git history, it belongs in the obser
|
|
|
101
101
|
|
|
102
102
|
Relevance levels (pick one per observation; this field drives future dropping):
|
|
103
103
|
|
|
104
|
-
- critical: user assertions about identity, role, or persistent preferences; explicit corrections ("no, don't do X"); concrete completions that future runs MUST NOT redo. These are load-bearing and
|
|
104
|
+
- critical: user assertions about identity, role, or persistent preferences; explicit corrections ("no, don't do X"); concrete completions that future runs MUST NOT redo. These are highest-resistance, load-bearing observations and require the strongest evidence before leaving active memory. Why this matters: if a "critical" item is lost, the assistant may redo finished work, contradict a correction, or misrepresent who the user is.
|
|
105
105
|
- high: non-trivial technical decisions, architectural direction, unresolved blockers, key constraints. Worth keeping across many compactions.
|
|
106
106
|
- medium: task-level context that helps within the current work but isn't durable. The default when you are unsure between medium and high.
|
|
107
107
|
- low: routine tool-call acks, repetitive status updates, content trivially re-derivable from recent messages. The dropper will drop these first.
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
9
9
|
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
10
|
-
import {
|
|
10
|
+
import { streamSimple } from "@earendil-works/pi-ai";
|
|
11
|
+
import { Type } from "typebox";
|
|
11
12
|
import type { Static } from "typebox";
|
|
12
13
|
import { hashId } from "../../ids.js";
|
|
13
14
|
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
|
|
@@ -15,6 +16,7 @@ import { truncateRecordContent } from "../../serialize.js";
|
|
|
15
16
|
import { REFLECTOR_SYSTEM } from "./prompts.js";
|
|
16
17
|
import { estimateStringTokens } from "../../tokens.js";
|
|
17
18
|
import { observationToSummaryLine, reflectionToSummaryLine, type Observation, type Reflection } from "../../ledger/index.js";
|
|
19
|
+
import type { ReflectionCoverageTier } from "../dropper/coverage.js";
|
|
18
20
|
|
|
19
21
|
interface RunReflectorArgs {
|
|
20
22
|
model: Model<any>;
|
|
@@ -28,6 +30,10 @@ interface RunReflectorArgs {
|
|
|
28
30
|
existingObservationsSummary?: string;
|
|
29
31
|
signal?: AbortSignal;
|
|
30
32
|
agentLoop?: typeof agentLoop;
|
|
33
|
+
/** Optional custom stream function bypassing agentLoop's default streamSimple.
|
|
34
|
+
* Used by the Symbol.for bridge to access native pi-ai provider registrations
|
|
35
|
+
* from jiti-loaded consolidation agents. */
|
|
36
|
+
streamFn?: (model: any, context: any, options: any) => any;
|
|
31
37
|
maxTurns?: number;
|
|
32
38
|
thinkingLevel?: ModelThinkingLevel;
|
|
33
39
|
}
|
|
@@ -143,7 +149,16 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
|
|
|
143
149
|
};
|
|
144
150
|
|
|
145
151
|
const loop = args.agentLoop ?? agentLoop;
|
|
146
|
-
|
|
152
|
+
// ── Bridge stream function ──
|
|
153
|
+
const PROVIDER_STREAMS_KEY = Symbol.for("pi-blackhole:provider-streams");
|
|
154
|
+
const bridgeStreamFn = (model: any, ctx: any, opts: any) => {
|
|
155
|
+
const providerStreams: Map<string, Function> | undefined = (globalThis as any)[PROVIDER_STREAMS_KEY];
|
|
156
|
+
if (!providerStreams) return streamSimple(model, ctx, opts);
|
|
157
|
+
const customFn = model?.api ? providerStreams.get(model.api) : undefined;
|
|
158
|
+
return customFn ? customFn(model, ctx, opts) : streamSimple(model, ctx, opts);
|
|
159
|
+
};
|
|
160
|
+
const streamFn = args.streamFn ?? bridgeStreamFn;
|
|
161
|
+
const stream = loop(prompts, context, config, signal, streamFn);
|
|
147
162
|
let agentError: string | undefined;
|
|
148
163
|
for await (const event of stream) {
|
|
149
164
|
// Tool execution collects records.
|
|
@@ -159,3 +174,37 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
|
|
|
159
174
|
if (agentError && accumulated.size === 0) throw new Error(`Reflector API error: ${agentError}`);
|
|
160
175
|
return accumulated.size > 0 ? Array.from(accumulated.values()) : undefined;
|
|
161
176
|
}
|
|
177
|
+
|
|
178
|
+
export function observationToReflectorLine(
|
|
179
|
+
observation: Observation,
|
|
180
|
+
coverage: ReflectionCoverageTier,
|
|
181
|
+
): string {
|
|
182
|
+
return `[${observation.id}] ${observation.timestamp} [${observation.relevance}] [coverage: ${coverage}] ${observation.content}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function summarizeSupportIdCounts(reflections: readonly Reflection[]): {
|
|
186
|
+
reflectionCount: number;
|
|
187
|
+
totalSupportIds: number;
|
|
188
|
+
minSupportIds: number;
|
|
189
|
+
maxSupportIds: number;
|
|
190
|
+
averageSupportIds: number;
|
|
191
|
+
histogram: Record<string, number>;
|
|
192
|
+
} {
|
|
193
|
+
if (reflections.length === 0) {
|
|
194
|
+
return { reflectionCount: 0, totalSupportIds: 0, minSupportIds: 0, maxSupportIds: 0, averageSupportIds: 0, histogram: {} };
|
|
195
|
+
}
|
|
196
|
+
const supportIdCounts = reflections.map((r) => r.supportingObservationIds.length);
|
|
197
|
+
const total = supportIdCounts.reduce((sum, c) => sum + c, 0);
|
|
198
|
+
const histogram: Record<string, number> = {};
|
|
199
|
+
for (const count of supportIdCounts) {
|
|
200
|
+
histogram[String(count)] = (histogram[String(count)] || 0) + 1;
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
reflectionCount: reflections.length,
|
|
204
|
+
totalSupportIds: total,
|
|
205
|
+
minSupportIds: Math.min(...supportIdCounts),
|
|
206
|
+
maxSupportIds: Math.max(...supportIdCounts),
|
|
207
|
+
averageSupportIds: total / reflections.length,
|
|
208
|
+
histogram,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
@@ -6,7 +6,8 @@ Your task is different from the observer's: you are not recording events, you ar
|
|
|
6
6
|
|
|
7
7
|
You receive:
|
|
8
8
|
- Current reflections: durable facts already crystallized.
|
|
9
|
-
- Current observations: active timestamped evidence lines, each shown as "[id] YYYY-MM-DD HH:MM [relevance] content".
|
|
9
|
+
- Current observations: active timestamped evidence lines, each shown as "[id] YYYY-MM-DD HH:MM [relevance] [coverage: none|partial|strong] content".
|
|
10
|
+
- Coverage tiers are review context: none means no current reflection supports the observation id, partial means exactly one current reflection supports it, and strong means two or more current reflections support it. Coverage is not a quota, target, priority score, or instruction to emit reflections.
|
|
10
11
|
|
|
11
12
|
What to emit:
|
|
12
13
|
- Emit only new durable reflections not already present in current reflections.
|
|
@@ -39,13 +40,16 @@ Focus on:
|
|
|
39
40
|
- Completed outcomes future runs must not redo.
|
|
40
41
|
- Durable blockers, invariants, and open decisions that should survive compaction.
|
|
41
42
|
|
|
42
|
-
Support ids:
|
|
43
|
+
Support ids and coverage stewardship:
|
|
43
44
|
- Every reflection must include supportingObservationIds from the current observations list.
|
|
44
|
-
-
|
|
45
|
-
- supportingObservationIds are
|
|
45
|
+
- First decide whether the reflection content passes the durable-value bar. Then audit support ids for that already-worthy reflection.
|
|
46
|
+
- supportingObservationIds are a coverage/provenance set and downstream dropper coverage evidence: include all current observation ids whose durable meaning is preserved by the reflection with equivalent fidelity and can later be treated as redundant active-memory detail.
|
|
47
|
+
- supportingObservationIds are not a checklist to cover every observation. Do not add ids merely to improve coverage counts, maximize support ids, maximize strong coverage, or unlock the dropper.
|
|
48
|
+
- False or inflated support ids can cause unsafe downstream dropper pruning, including removal of high-resistance active observations whose meaning was not actually preserved.
|
|
46
49
|
- Include additional observation ids only when the reflection preserves their durable meaning with equivalent fidelity.
|
|
47
50
|
- Leave observations unsupported when their details are still active working state, too specific to compress safely, or not yet durable enough.
|
|
48
51
|
- Do not include observations whose unique exact detail, current task state, user correction, user constraint, or concrete completion is not captured by the reflection.
|
|
52
|
+
- If no candidate reflection passes the durable-value bar, emit zero reflections even when observations have coverage: none.
|
|
49
53
|
- Never invent observation ids. Proposals with missing, empty, or invalid supportingObservationIds are rejected.
|
|
50
54
|
|
|
51
55
|
User assertions are authoritative. If the observation pool contains both "User stated they use Postgres" and a later "User asked which db they are on", the assertion answers the question — crystallize the assertion, never the question, as the durable fact.
|
package/src/om/consolidation.ts
CHANGED
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
rawTokensSinceReflectionCoverage,
|
|
50
50
|
reflectionToSummaryLine,
|
|
51
51
|
reflectionsCreatedAfterIndex,
|
|
52
|
+
selectPriorObservations,
|
|
52
53
|
type Entry,
|
|
53
54
|
type Observation,
|
|
54
55
|
type Reflection,
|
|
@@ -125,54 +126,7 @@ function mergeReflections(existing: Reflection[], additional: Reflection[]): Ref
|
|
|
125
126
|
return merged;
|
|
126
127
|
}
|
|
127
128
|
|
|
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
129
|
|
|
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
130
|
|
|
177
131
|
/**
|
|
178
132
|
* Extract all pending observations from accumulated batches that were recorded
|