pi-observational-memory 2.4.3 → 3.0.1
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 +318 -103
- package/package.json +9 -9
- package/src/agents/dropper/agent.ts +281 -0
- package/src/agents/dropper/coverage.ts +128 -0
- package/src/agents/dropper/pool.ts +67 -0
- package/src/agents/dropper/prompts.ts +48 -0
- package/src/{observer.ts → agents/observer/agent.ts} +11 -13
- package/src/agents/observer/prompts.ts +119 -0
- package/src/agents/reflector/agent.ts +203 -0
- package/src/agents/reflector/prompts.ts +81 -0
- package/src/clipboard.ts +63 -0
- package/src/commands/status.ts +79 -78
- package/src/commands/view.ts +58 -66
- package/src/config.ts +78 -55
- package/src/debug-log.ts +24 -5
- package/src/hooks/compaction-hook.ts +24 -369
- package/src/hooks/compaction-trigger.ts +12 -21
- package/src/hooks/consolidation-trigger.ts +368 -0
- package/src/index.ts +3 -3
- package/src/model-budget.ts +1 -1
- package/src/runtime.ts +46 -19
- package/src/serialize.ts +1 -1
- package/src/session-ledger/fold.ts +100 -0
- package/src/session-ledger/index.ts +6 -0
- package/src/session-ledger/progress.ts +129 -0
- package/src/session-ledger/projection.ts +220 -0
- package/src/session-ledger/recall.ts +237 -0
- package/src/session-ledger/render-summary.ts +31 -0
- package/src/session-ledger/types.ts +200 -0
- package/src/tokens.ts +1 -1
- package/src/tools/recall-observation.ts +84 -214
- package/src/branch.ts +0 -577
- package/src/compaction.ts +0 -1030
- package/src/hooks/observer-trigger.ts +0 -129
- package/src/progress.ts +0 -155
- package/src/prompts.ts +0 -302
- package/src/relevance.ts +0 -15
- package/src/types.ts +0 -155
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
3
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
4
|
+
import type { Static } from "typebox";
|
|
5
|
+
import { debugLog } from "../../debug-log.js";
|
|
6
|
+
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
|
|
7
|
+
import { reflectionToSummaryLine, type Observation, type Reflection } from "../../session-ledger/index.js";
|
|
8
|
+
import { DROPPER_SYSTEM } from "./prompts.js";
|
|
9
|
+
import {
|
|
10
|
+
REFLECTION_COVERAGE_DROP_RANK,
|
|
11
|
+
coverageTierForObservation,
|
|
12
|
+
reflectionCoverageMap,
|
|
13
|
+
summarizeCoverageByRelevance,
|
|
14
|
+
summarizeCoverageByRelevanceForIds,
|
|
15
|
+
observationToDropperLine,
|
|
16
|
+
} from "./coverage.js";
|
|
17
|
+
import { observationPoolMetrics } from "./pool.js";
|
|
18
|
+
export {
|
|
19
|
+
maxDropCountForPool,
|
|
20
|
+
observationPoolFullness,
|
|
21
|
+
observationPoolMetrics,
|
|
22
|
+
} from "./pool.js";
|
|
23
|
+
export type { ObservationPoolMetrics } from "./pool.js";
|
|
24
|
+
export {
|
|
25
|
+
REFLECTION_COVERAGE_TIERS,
|
|
26
|
+
coverageTierForObservation,
|
|
27
|
+
emptyCoverageSummaryByRelevance,
|
|
28
|
+
observationToDropperLine,
|
|
29
|
+
reflectionCoverageMap,
|
|
30
|
+
reflectionCoverageTierForCount,
|
|
31
|
+
reflectionSupportCounts,
|
|
32
|
+
summarizeCoverageByRelevance,
|
|
33
|
+
summarizeCoverageByRelevanceForIds,
|
|
34
|
+
summarizeCoverageTransitionsByRelevance,
|
|
35
|
+
} from "./coverage.js";
|
|
36
|
+
export type { CoverageSummaryByRelevance, CoverageTransitionSummaryByRelevance, ReflectionCoverageTier } from "./coverage.js";
|
|
37
|
+
|
|
38
|
+
interface RunDropperArgs {
|
|
39
|
+
model: Model<any>;
|
|
40
|
+
apiKey: string;
|
|
41
|
+
headers?: Record<string, string>;
|
|
42
|
+
reflections: Reflection[];
|
|
43
|
+
observations: Observation[];
|
|
44
|
+
targetTokens: number;
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
agentLoop?: typeof agentLoop;
|
|
47
|
+
maxTurns?: number;
|
|
48
|
+
thinkingLevel?: ModelThinkingLevel;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const RELEVANCE_DROP_RANK: Record<Observation["relevance"], number> = {
|
|
52
|
+
low: 0,
|
|
53
|
+
medium: 1,
|
|
54
|
+
high: 2,
|
|
55
|
+
critical: 3,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const DropObservationsSchema = Type.Object({
|
|
59
|
+
ids: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }),
|
|
60
|
+
reason: Type.Optional(Type.String()),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
type DropObservationsArgs = Static<typeof DropObservationsSchema>;
|
|
64
|
+
|
|
65
|
+
function joinOrEmpty(items: string[]): string {
|
|
66
|
+
return items.length ? items.join("\n") : "(none yet)";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function relevanceCounts(observations: readonly Observation[]): Record<Observation["relevance"], number> {
|
|
70
|
+
return observations.reduce<Record<Observation["relevance"], number>>((counts, observation) => {
|
|
71
|
+
counts[observation.relevance]++;
|
|
72
|
+
return counts;
|
|
73
|
+
}, { low: 0, medium: 0, high: 0, critical: 0 });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function normalizeDropObservationIds(
|
|
77
|
+
ids: readonly string[] | undefined,
|
|
78
|
+
observations: readonly Observation[],
|
|
79
|
+
): string[] | undefined {
|
|
80
|
+
if (!ids || ids.length === 0) return undefined;
|
|
81
|
+
const allowed = new Map(observations.map((observation) => [observation.id, observation]));
|
|
82
|
+
const result: string[] = [];
|
|
83
|
+
const seen = new Set<string>();
|
|
84
|
+
for (const id of ids) {
|
|
85
|
+
const observation = allowed.get(id);
|
|
86
|
+
if (!observation) continue;
|
|
87
|
+
if (seen.has(id)) continue;
|
|
88
|
+
seen.add(id);
|
|
89
|
+
result.push(id);
|
|
90
|
+
}
|
|
91
|
+
return result.length > 0 ? result : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function timestampRank(timestamp: string): number {
|
|
95
|
+
const parsed = Date.parse(timestamp);
|
|
96
|
+
return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function selectDropCandidates(
|
|
100
|
+
ids: readonly string[],
|
|
101
|
+
observations: readonly Observation[],
|
|
102
|
+
maxDrops: number,
|
|
103
|
+
reflections: readonly Reflection[] = [],
|
|
104
|
+
): string[] {
|
|
105
|
+
if (maxDrops <= 0 || ids.length === 0) return [];
|
|
106
|
+
|
|
107
|
+
const byId = new Map(observations.map((observation) => [observation.id, observation]));
|
|
108
|
+
const coverageById = reflectionCoverageMap(observations, reflections);
|
|
109
|
+
const firstProposalIndex = new Map<string, number>();
|
|
110
|
+
for (let i = 0; i < ids.length; i++) {
|
|
111
|
+
const id = ids[i];
|
|
112
|
+
if (!firstProposalIndex.has(id)) firstProposalIndex.set(id, i);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return Array.from(firstProposalIndex.entries())
|
|
116
|
+
.map(([id, index]) => ({ id, index, observation: byId.get(id) }))
|
|
117
|
+
.filter((candidate): candidate is { id: string; index: number; observation: Observation } =>
|
|
118
|
+
candidate.observation !== undefined
|
|
119
|
+
)
|
|
120
|
+
.sort((a, b) => {
|
|
121
|
+
const coverageDelta = REFLECTION_COVERAGE_DROP_RANK[coverageTierForObservation(a.observation, coverageById)]
|
|
122
|
+
- REFLECTION_COVERAGE_DROP_RANK[coverageTierForObservation(b.observation, coverageById)];
|
|
123
|
+
const relevanceDelta = RELEVANCE_DROP_RANK[a.observation.relevance] - RELEVANCE_DROP_RANK[b.observation.relevance];
|
|
124
|
+
const ageDelta = timestampRank(a.observation.timestamp) - timestampRank(b.observation.timestamp);
|
|
125
|
+
return coverageDelta || relevanceDelta || ageDelta || a.index - b.index;
|
|
126
|
+
})
|
|
127
|
+
.slice(0, maxDrops)
|
|
128
|
+
.map((candidate) => candidate.id);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function runDropper(args: RunDropperArgs): Promise<string[] | undefined> {
|
|
132
|
+
const { model, apiKey, headers, reflections, observations, targetTokens, signal } = args;
|
|
133
|
+
if (observations.length === 0) return undefined;
|
|
134
|
+
|
|
135
|
+
const metrics = observationPoolMetrics(observations, targetTokens);
|
|
136
|
+
const { observationTokens, fullness, tokensOverTarget, maxDropsAllowed } = metrics;
|
|
137
|
+
const coverageById = reflectionCoverageMap(observations, reflections);
|
|
138
|
+
const coverageSummaryByRelevance = summarizeCoverageByRelevance(observations, coverageById);
|
|
139
|
+
debugLog("dropper.agent_start", {
|
|
140
|
+
activeObservationCount: observations.length,
|
|
141
|
+
reflectionCount: reflections.length,
|
|
142
|
+
observationTokens,
|
|
143
|
+
targetTokens,
|
|
144
|
+
tokensOverTarget,
|
|
145
|
+
fullness,
|
|
146
|
+
maxDropsAllowed,
|
|
147
|
+
relevanceCounts: relevanceCounts(observations),
|
|
148
|
+
coverageSummaryByRelevance,
|
|
149
|
+
});
|
|
150
|
+
if (maxDropsAllowed <= 0) {
|
|
151
|
+
debugLog("dropper.result", {
|
|
152
|
+
reason: "not_over_target",
|
|
153
|
+
toolCallCount: 0,
|
|
154
|
+
rawRequestedIdsCount: 0,
|
|
155
|
+
acceptedCandidateCount: 0,
|
|
156
|
+
selectedDropsCount: 0,
|
|
157
|
+
selectedDropTokens: 0,
|
|
158
|
+
selectedCoverageSummaryByRelevance: summarizeCoverageByRelevanceForIds([], observations, coverageById),
|
|
159
|
+
maxDropsAllowed,
|
|
160
|
+
});
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const proposedDropIds: string[] = [];
|
|
165
|
+
const proposed = new Set<string>();
|
|
166
|
+
const allowed = new Map(observations.map((observation) => [observation.id, observation]));
|
|
167
|
+
let toolCallCount = 0;
|
|
168
|
+
let rawRequestedIdsCount = 0;
|
|
169
|
+
let missingIdsCount = 0;
|
|
170
|
+
let criticalCandidateIdsCount = 0;
|
|
171
|
+
let duplicateInRequestCount = 0;
|
|
172
|
+
let duplicateInRunCount = 0;
|
|
173
|
+
|
|
174
|
+
const dropObservations: AgentTool<typeof DropObservationsSchema> = {
|
|
175
|
+
name: "drop_observations",
|
|
176
|
+
label: "Drop observations",
|
|
177
|
+
description: "Propose active observation ids that are safe to remove from compacted memory.",
|
|
178
|
+
parameters: DropObservationsSchema,
|
|
179
|
+
execute: async (_id, params: DropObservationsArgs) => {
|
|
180
|
+
toolCallCount++;
|
|
181
|
+
rawRequestedIdsCount += params.ids.length;
|
|
182
|
+
const seenInRequest = new Set<string>();
|
|
183
|
+
let added = 0;
|
|
184
|
+
let requestMissingIds = 0;
|
|
185
|
+
let requestCriticalCandidateIds = 0;
|
|
186
|
+
let requestDuplicateIds = 0;
|
|
187
|
+
let requestDuplicateInRunIds = 0;
|
|
188
|
+
for (const id of params.ids) {
|
|
189
|
+
const observation = allowed.get(id);
|
|
190
|
+
if (!observation) {
|
|
191
|
+
missingIdsCount++;
|
|
192
|
+
requestMissingIds++;
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (seenInRequest.has(id)) {
|
|
196
|
+
duplicateInRequestCount++;
|
|
197
|
+
requestDuplicateIds++;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
seenInRequest.add(id);
|
|
201
|
+
if (proposed.has(id)) {
|
|
202
|
+
duplicateInRunCount++;
|
|
203
|
+
requestDuplicateInRunIds++;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
proposed.add(id);
|
|
207
|
+
proposedDropIds.push(id);
|
|
208
|
+
if (observation.relevance === "critical") {
|
|
209
|
+
criticalCandidateIdsCount++;
|
|
210
|
+
requestCriticalCandidateIds++;
|
|
211
|
+
}
|
|
212
|
+
added++;
|
|
213
|
+
}
|
|
214
|
+
debugLog("dropper.tool_call", {
|
|
215
|
+
toolCallCount,
|
|
216
|
+
rawRequestedIdsCount: params.ids.length,
|
|
217
|
+
acceptedIdsCount: added,
|
|
218
|
+
missingIdsCount: requestMissingIds,
|
|
219
|
+
criticalCandidateIdsCount: requestCriticalCandidateIds,
|
|
220
|
+
duplicateInRequestCount: requestDuplicateIds,
|
|
221
|
+
duplicateInRunCount: requestDuplicateInRunIds,
|
|
222
|
+
totalCandidates: proposedDropIds.length,
|
|
223
|
+
maxDropsAllowed,
|
|
224
|
+
});
|
|
225
|
+
return {
|
|
226
|
+
content: [{ type: "text", text: `Queued ${added} drop candidate${added === 1 ? "" : "s"}. Candidates this run: ${proposedDropIds.length}. Maximum drops allowed: ${maxDropsAllowed}.` }],
|
|
227
|
+
details: { added, totalCandidates: proposedDropIds.length, maxDropsAllowed },
|
|
228
|
+
};
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const fullnessPercent = Math.round(fullness * 100);
|
|
233
|
+
const userText = `CURRENT REFLECTIONS:\n${joinOrEmpty(reflections.map(reflectionToSummaryLine))}\n\nCURRENT OBSERVATIONS:\n${joinOrEmpty(observations.map((observation) => observationToDropperLine(observation, coverageTierForObservation(observation, coverageById))))}\n\nActive observation pool: ~${observationTokens.toLocaleString()} tokens; target: ~${targetTokens.toLocaleString()} tokens; fullness against target: ~${fullnessPercent.toLocaleString()}%; over target by ~${tokensOverTarget.toLocaleString()} tokens.\nMaximum drops allowed this run: ${maxDropsAllowed.toLocaleString()} observation${maxDropsAllowed === 1 ? "" : "s"}. This maximum is sized to move the active pool toward the target if every proposed drop is clearly safe.\nThis maximum is a hard upper bound, not a target. Drop fewer or none if fewer observations are clearly safe.`;
|
|
234
|
+
const prompts: Message[] = [{ role: "user", content: [{ type: "text", text: userText }], timestamp: Date.now() }];
|
|
235
|
+
const context: AgentContext = { systemPrompt: DROPPER_SYSTEM, messages: [], tools: [dropObservations as AgentTool<any>] };
|
|
236
|
+
const reasoning = (model as { reasoning?: unknown }).reasoning;
|
|
237
|
+
const thinkingLevel = args.thinkingLevel ?? "low";
|
|
238
|
+
const effectiveMaxTurns = args.maxTurns && args.maxTurns > 0 ? args.maxTurns : undefined;
|
|
239
|
+
let turnCount = 0;
|
|
240
|
+
const config: AgentLoopConfig = {
|
|
241
|
+
model,
|
|
242
|
+
apiKey,
|
|
243
|
+
headers,
|
|
244
|
+
maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
|
|
245
|
+
convertToLlm: (msgs) => msgs as Message[],
|
|
246
|
+
toolExecution: "sequential",
|
|
247
|
+
...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
|
|
248
|
+
...(effectiveMaxTurns !== undefined ? { shouldStopAfterTurn: () => ++turnCount >= effectiveMaxTurns } : {}),
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const loop = args.agentLoop ?? agentLoop;
|
|
252
|
+
const stream = loop(prompts, context, config, signal);
|
|
253
|
+
for await (const _event of stream) {
|
|
254
|
+
// Tool execution collects candidate ids.
|
|
255
|
+
}
|
|
256
|
+
await stream.result();
|
|
257
|
+
const droppedIds = selectDropCandidates(proposedDropIds, observations, maxDropsAllowed, reflections);
|
|
258
|
+
const reason = droppedIds.length > 0
|
|
259
|
+
? "selected_nonempty"
|
|
260
|
+
: toolCallCount === 0
|
|
261
|
+
? "no_tool_call"
|
|
262
|
+
: proposedDropIds.length === 0
|
|
263
|
+
? "all_filtered"
|
|
264
|
+
: "selected_empty";
|
|
265
|
+
const selectedDropTokens = droppedIds.reduce((sum, id) => sum + (allowed.get(id)?.tokenCount ?? 0), 0);
|
|
266
|
+
debugLog("dropper.result", {
|
|
267
|
+
reason,
|
|
268
|
+
toolCallCount,
|
|
269
|
+
rawRequestedIdsCount,
|
|
270
|
+
missingIdsCount,
|
|
271
|
+
criticalCandidateIdsCount,
|
|
272
|
+
duplicateInRequestCount,
|
|
273
|
+
duplicateInRunCount,
|
|
274
|
+
acceptedCandidateCount: proposedDropIds.length,
|
|
275
|
+
selectedDropsCount: droppedIds.length,
|
|
276
|
+
selectedDropTokens,
|
|
277
|
+
selectedCoverageSummaryByRelevance: summarizeCoverageByRelevanceForIds(droppedIds, observations, coverageById),
|
|
278
|
+
maxDropsAllowed,
|
|
279
|
+
});
|
|
280
|
+
return droppedIds.length > 0 ? droppedIds : undefined;
|
|
281
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { Observation, Reflection } from "../../session-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
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Observation } from "../../session-ledger/index.js";
|
|
2
|
+
|
|
3
|
+
export type ObservationPoolMetrics = {
|
|
4
|
+
observationTokens: number;
|
|
5
|
+
targetTokens: number;
|
|
6
|
+
tokensOverTarget: number;
|
|
7
|
+
fullness: number;
|
|
8
|
+
activeObservationCount: number;
|
|
9
|
+
droppableCount: number;
|
|
10
|
+
maxDropsAllowed: number;
|
|
11
|
+
overTarget: boolean;
|
|
12
|
+
ready: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function observationTokenSum(observations: readonly { tokenCount: number }[]): number {
|
|
16
|
+
return observations.reduce((sum, observation) => sum + observation.tokenCount, 0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function observationPoolFullness(observationTokens: number, targetTokens: number): number {
|
|
20
|
+
if (!Number.isFinite(observationTokens) || observationTokens <= 0) return 0;
|
|
21
|
+
if (!Number.isFinite(targetTokens) || targetTokens <= 0) return 0;
|
|
22
|
+
return observationTokens / targetTokens;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function droppableObservationCount(observations: readonly Observation[]): number {
|
|
26
|
+
return observations.length;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function maxDropCountForPool(observations: readonly Observation[], observationTokens: number, targetTokens: number): number {
|
|
30
|
+
const activeObservationCount = observations.length;
|
|
31
|
+
if (activeObservationCount === 0) return 0;
|
|
32
|
+
if (!Number.isFinite(observationTokens) || observationTokens <= 0) return 0;
|
|
33
|
+
if (!Number.isFinite(targetTokens) || targetTokens < 0) return 0;
|
|
34
|
+
|
|
35
|
+
const tokensOverTarget = observationTokens - targetTokens;
|
|
36
|
+
if (tokensOverTarget <= 0) return 0;
|
|
37
|
+
|
|
38
|
+
const averageObservationTokens = observationTokens / activeObservationCount;
|
|
39
|
+
if (!Number.isFinite(averageObservationTokens) || averageObservationTokens <= 0) return 0;
|
|
40
|
+
|
|
41
|
+
const estimatedDrops = Math.ceil(tokensOverTarget / averageObservationTokens);
|
|
42
|
+
return Math.min(activeObservationCount, Math.max(1, estimatedDrops));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function observationPoolMetrics(
|
|
46
|
+
observations: readonly Observation[],
|
|
47
|
+
targetTokens: number,
|
|
48
|
+
): ObservationPoolMetrics {
|
|
49
|
+
const observationTokens = observationTokenSum(observations);
|
|
50
|
+
const fullness = observationPoolFullness(observationTokens, targetTokens);
|
|
51
|
+
const activeObservationCount = observations.length;
|
|
52
|
+
const droppableCount = droppableObservationCount(observations);
|
|
53
|
+
const tokensOverTarget = Math.max(0, observationTokens - targetTokens);
|
|
54
|
+
const maxDropsAllowed = maxDropCountForPool(observations, observationTokens, targetTokens);
|
|
55
|
+
const overTarget = Number.isFinite(targetTokens) && targetTokens >= 0 && observationTokens > targetTokens;
|
|
56
|
+
return {
|
|
57
|
+
observationTokens,
|
|
58
|
+
targetTokens,
|
|
59
|
+
tokensOverTarget,
|
|
60
|
+
fullness,
|
|
61
|
+
activeObservationCount,
|
|
62
|
+
droppableCount,
|
|
63
|
+
maxDropsAllowed,
|
|
64
|
+
overTarget,
|
|
65
|
+
ready: overTarget && maxDropsAllowed > 0,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const DROPPER_SYSTEM = `You are the dropper agent for a coding assistant.
|
|
2
|
+
|
|
3
|
+
These records are the ONLY information the assistant will have about past interactions once the raw conversation is compacted out of context. Dropping the wrong observation can make future work repeat, contradict, or misremember the user. Take this seriously.
|
|
4
|
+
|
|
5
|
+
Your job is to identify only the safest active observations to remove from compacted memory by calling drop_observations with their ids. Default action is KEEP. When uncertain, keep the observation.
|
|
6
|
+
|
|
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
|
+
|
|
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.
|
|
10
|
+
|
|
11
|
+
What to drop, in priority order:
|
|
12
|
+
- Redundant observations whose durable meaning is already captured by current reflections with equivalent fidelity.
|
|
13
|
+
- Superseded observations where a later observation clearly replaces the older state.
|
|
14
|
+
- Repeated routine tool acknowledgements or low-signal progress updates that do not carry decisions, constraints, exact errors, or user-specific facts.
|
|
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.
|
|
18
|
+
|
|
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
|
+
|
|
24
|
+
Relevance guidance. Relevance is importance/resistance, not an absolute keep/drop lock:
|
|
25
|
+
- low: consider first, but drop only when it carries no unique detail, decision, state, error, identifier, or user-specific fact.
|
|
26
|
+
- medium: drop when redundant with reflections or other observations, or when the work state is clearly obsolete.
|
|
27
|
+
- high: drop only when clearly superseded or already captured by a reflection with equivalent fidelity.
|
|
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
|
+
|
|
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
|
+
|
|
32
|
+
Preservation floor. Regardless of relevance label, budget pressure, coverage, or age, do not drop observations that uniquely carry any of the following:
|
|
33
|
+
- User preferences, constraints, corrections, or identity/role facts.
|
|
34
|
+
- Concrete completions that future runs must not redo.
|
|
35
|
+
- Named identifiers, file paths, function names, package names, tickets, commit SHAs, handles, or exact commands.
|
|
36
|
+
- Exact error messages, diagnostic output, or test failure names.
|
|
37
|
+
- Architectural or technical decisions and their rationale.
|
|
38
|
+
- Dates of specific events, deadlines, meetings, migrations, or incidents.
|
|
39
|
+
- Current unresolved blockers, TODOs, partial work, or decisions waiting on the user.
|
|
40
|
+
- Non-standard user terminology or unusual phrasing needed for future recognition.
|
|
41
|
+
|
|
42
|
+
What you cannot do:
|
|
43
|
+
- You cannot merge observations.
|
|
44
|
+
- You cannot rewrite or edit observations.
|
|
45
|
+
- You cannot add new observations or reflections.
|
|
46
|
+
- You can only call drop_observations with ids from the current observations list.
|
|
47
|
+
|
|
48
|
+
Do not force drops you do not believe in. If no observations are safe to drop, do not call the tool and reply briefly. Hitting the budget or maximum count is less important than preserving load-bearing memory.`;
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@
|
|
2
|
-
import type { Message, Model, ModelThinkingLevel } from "@
|
|
3
|
-
import { Type } from "@
|
|
1
|
+
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
3
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
4
4
|
import type { Static } from "typebox";
|
|
5
|
-
import { hashId } from "
|
|
6
|
-
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "
|
|
5
|
+
import { hashId } from "../../ids.js";
|
|
6
|
+
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
|
|
7
7
|
import { OBSERVER_SYSTEM } from "./prompts.js";
|
|
8
|
-
import { nowTimestamp, truncateRecordContent } from "
|
|
9
|
-
import type {
|
|
8
|
+
import { nowTimestamp, truncateRecordContent } from "../../serialize.js";
|
|
9
|
+
import type { Observation, Relevance } from "../../session-ledger/index.js";
|
|
10
|
+
import { estimateStringTokens } from "../../tokens.js";
|
|
10
11
|
|
|
11
12
|
interface RunObserverArgs {
|
|
12
13
|
model: Model<any>;
|
|
@@ -80,12 +81,12 @@ export function normalizeSourceEntryIds(
|
|
|
80
81
|
return Array.from(seen).sort((a, b) => (allowedOrder.get(a) ?? 0) - (allowedOrder.get(b) ?? 0));
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
export async function runObserver(args: RunObserverArgs): Promise<
|
|
84
|
+
export async function runObserver(args: RunObserverArgs): Promise<Observation[] | undefined> {
|
|
84
85
|
const { model, apiKey, headers, priorReflections, priorObservations, chunk, allowedSourceEntryIds, signal } = args;
|
|
85
86
|
const conversation = chunk.trim();
|
|
86
87
|
if (!conversation) return undefined;
|
|
87
88
|
|
|
88
|
-
const accumulated = new Map<string,
|
|
89
|
+
const accumulated = new Map<string, Observation>();
|
|
89
90
|
|
|
90
91
|
const recordObservations: AgentTool<typeof RecordObservationsSchema> = {
|
|
91
92
|
name: "record_observations",
|
|
@@ -117,6 +118,7 @@ export async function runObserver(args: RunObserverArgs): Promise<ObservationRec
|
|
|
117
118
|
timestamp: obs.timestamp,
|
|
118
119
|
relevance: obs.relevance as Relevance,
|
|
119
120
|
sourceEntryIds,
|
|
121
|
+
tokenCount: estimateStringTokens(content),
|
|
120
122
|
});
|
|
121
123
|
added++;
|
|
122
124
|
}
|
|
@@ -193,7 +195,3 @@ ${conversation}`;
|
|
|
193
195
|
if (accumulated.size === 0) return undefined;
|
|
194
196
|
return Array.from(accumulated.values());
|
|
195
197
|
}
|
|
196
|
-
|
|
197
|
-
export function observationsToPromptLines(records: ObservationRecord[]): string[] {
|
|
198
|
-
return records.map((r) => `[${r.id}] ${r.timestamp} [${r.relevance}] ${r.content}`);
|
|
199
|
-
}
|