pi-observational-memory 3.0.0 → 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 +13 -7
- package/package.json +1 -1
- package/src/agents/dropper/agent.ts +139 -10
- package/src/agents/dropper/coverage.ts +128 -0
- package/src/agents/dropper/pool.ts +1 -1
- package/src/agents/dropper/prompts.ts +12 -7
- package/src/agents/observer/prompts.ts +1 -1
- package/src/agents/reflector/agent.ts +72 -3
- package/src/agents/reflector/prompts.ts +8 -4
- package/src/debug-log.ts +25 -2
- package/src/hooks/consolidation-trigger.ts +40 -3
package/README.md
CHANGED
|
@@ -87,7 +87,7 @@ Examples:
|
|
|
87
87
|
* the project prefers minimal abstractions over framework-heavy patterns
|
|
88
88
|
* the branch is about improving long-session agent memory
|
|
89
89
|
|
|
90
|
-
Reflections help the agent stay oriented over time.
|
|
90
|
+
Reflections help the agent stay oriented over time. The reflector treats coverage as stewardship: every active observation it reviews includes a `none`, `partial`, or `strong` coverage tier, but those tiers are review context rather than quotas. When the reflector emits a durable reflection, its support ids should cover all and only the observations whose durable meaning is actually preserved, because those ids later become dropper coverage evidence.
|
|
91
91
|
|
|
92
92
|
Together, observations and reflections let Pi carry the important parts of the session forward without depending on fragile summary chains.
|
|
93
93
|
|
|
@@ -229,14 +229,14 @@ Most users can start with the defaults and tune only if they have a specific rea
|
|
|
229
229
|
| Setting | Default | Meaning |
|
|
230
230
|
| --------------------------- | ------------- | ------------------------------------------------------------------------------------------------- |
|
|
231
231
|
| `observeAfterTokens` | `10000` | Raw/source token threshold for observation runs. |
|
|
232
|
-
| `reflectAfterTokens` | `20000` | Raw/source token threshold for reflection
|
|
232
|
+
| `reflectAfterTokens` | `20000` | Raw/source token threshold for reflection runs; successful reflection creates dropper opportunities. |
|
|
233
233
|
| `compactAfterTokens` | `81000` | Raw/source token threshold for proactive auto-compaction. |
|
|
234
234
|
| `observationsPoolMaxTokens` | `20000` | Observation-token budget used for compaction full-fold pressure. |
|
|
235
|
-
| `observationsPoolTargetTokens` | half of max | Active
|
|
235
|
+
| `observationsPoolTargetTokens` | half of max | Active observation target used by post-reflection dropper maintenance. |
|
|
236
236
|
| `agentMaxTurns` | `16` | Shared turn cap for background memory-agent loops. |
|
|
237
237
|
| `model` | session model | Optional memory-worker model override: `{ provider, id, thinking }`. |
|
|
238
238
|
| `passive` | `false` | Disables proactive background observation, reflection, maintenance, and auto-compaction triggers. |
|
|
239
|
-
| `debugLog` | `false` | Writes extension debug events to Pi's agent directory.
|
|
239
|
+
| `debugLog` | `false` | Writes opt-in per-session extension debug events to Pi's agent directory. |
|
|
240
240
|
|
|
241
241
|
Valid `model.thinking` values are:
|
|
242
242
|
|
|
@@ -249,6 +249,12 @@ Valid `model.thinking` values are:
|
|
|
249
249
|
|
|
250
250
|
If no `model` is configured, memory workers use the session model.
|
|
251
251
|
|
|
252
|
+
`observationsPoolMaxTokens` and `observationsPoolTargetTokens` intentionally describe different pools. Max tokens control when compaction performs a full fold over visible memory. Target tokens control the folded active observation pool that the dropper maintains after successful reflection. If the target is omitted, it defaults to half of max.
|
|
253
|
+
|
|
254
|
+
Dropper pruning balances age, relevance, and reflection coverage. Relevance is importance/resistance, not a permanent active-memory pin: `critical` observations require the strongest evidence but can be dropped when they are older and safely represented by reflections, superseded by newer memory, redundant, or obsolete. Dropper input annotates each active observation with deterministic coverage evidence: `none`, `partial`, or `strong`; coverage guides model judgment and is not an automatic drop rule. Dropping removes observations from active memory, not ledger history.
|
|
255
|
+
|
|
256
|
+
When `debugLog` is enabled, debug events are written as local NDJSON files under Pi's agent directory. Normal sessions write to `observational-memory/debug/<session-id>.ndjson`; contexts without a session id fall back to `observational-memory/debug.ndjson`. Debug rows include `sessionId` and per-consolidation `runId`, so a session file can still be filtered to one observer/reflector/dropper run.
|
|
257
|
+
|
|
252
258
|
For details and tuning guidance, see [`docs/configuration.md`](docs/configuration.md).
|
|
253
259
|
|
|
254
260
|
---
|
|
@@ -257,7 +263,7 @@ For details and tuning guidance, see [`docs/configuration.md`](docs/configuratio
|
|
|
257
263
|
|
|
258
264
|
| Surface | What it does |
|
|
259
265
|
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
260
|
-
| `/om-status` | Shows memory counts, plain `+N` / `-N` visible/full drift suffixes, progress clocks, visible and active
|
|
266
|
+
| `/om-status` | Shows memory counts, plain `+N` / `-N` visible/full drift suffixes, progress clocks, visible and active observation pool pressure, passive/in-flight state, and last worker errors. |
|
|
261
267
|
| `/om-view` | Shows current visible memory and attempts to copy the rendered memory text to the clipboard. |
|
|
262
268
|
| `/om-view full` | Shows the full current memory state for the branch and attempts to copy the rendered memory text to the clipboard. |
|
|
263
269
|
| `recall` agent tool | Recovers source evidence for a 12-character observation/reflection id on the current branch. It is not semantic search or a transcript browser. |
|
|
@@ -304,7 +310,7 @@ Current behavior:
|
|
|
304
310
|
* **Fast compaction.** `session_before_compact` does not call a model or wait for background workers. It renders the current prepared memory state.
|
|
305
311
|
* **Background memory work.** Observation and reflection work run from `turn_end` when their token clocks are due; dropper work runs only after successful reflection and prunes the folded active observation ledger toward `observationsPoolTargetTokens`.
|
|
306
312
|
* **Source-backed recall.** Observations and reflections can be traced back through the `recall` tool.
|
|
307
|
-
* **Visible/full views.** `/om-view` shows visible memory and `/om-view full` shows the full current memory state. Use `/om-status` for visible-vs-full drift and for the separate visible observation pool vs active
|
|
313
|
+
* **Visible/full views.** `/om-view` shows visible memory and `/om-view full` shows the full current memory state. Use `/om-status` for visible-vs-full drift and for the separate visible observation pool vs active observation pool.
|
|
308
314
|
* **No V2 compatibility layer.** Old V2 settings and memory entries are ignored rather than migrated.
|
|
309
315
|
|
|
310
316
|
---
|
|
@@ -325,7 +331,7 @@ What this means in practice:
|
|
|
325
331
|
| ---------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
326
332
|
| `observationThresholdTokens` | `observeAfterTokens` | Rename. Same rough role: observation cadence based on raw/source tokens. |
|
|
327
333
|
| `compactionThresholdTokens` | `compactAfterTokens` | Rename. Same rough role: proactive compaction cadence. |
|
|
328
|
-
| `reflectionThresholdTokens` | `reflectAfterTokens`, `observationsPoolMaxTokens`, and/or `observationsPoolTargetTokens` | Split. Use `reflectAfterTokens` for reflection scheduling, `observationsPoolMaxTokens` for compaction full-fold pressure, and `observationsPoolTargetTokens` for dropper active
|
|
334
|
+
| `reflectionThresholdTokens` | `reflectAfterTokens`, `observationsPoolMaxTokens`, and/or `observationsPoolTargetTokens` | Split. Use `reflectAfterTokens` for reflection scheduling, `observationsPoolMaxTokens` for compaction full-fold pressure, and `observationsPoolTargetTokens` for dropper active observation maintenance. |
|
|
329
335
|
| `compactionModel` | `model` | Move `{ provider, id }` to `model`. |
|
|
330
336
|
| `thinkingLevel` | `model.thinking` | Move under `model`. |
|
|
331
337
|
| `observerMaxTurnsPerRun` | `agentMaxTurns` | Replace with the shared memory-agent turn cap. |
|
package/package.json
CHANGED
|
@@ -2,9 +2,18 @@ import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } fr
|
|
|
2
2
|
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Type } from "@earendil-works/pi-ai";
|
|
4
4
|
import type { Static } from "typebox";
|
|
5
|
+
import { debugLog } from "../../debug-log.js";
|
|
5
6
|
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
|
|
6
|
-
import {
|
|
7
|
+
import { reflectionToSummaryLine, type Observation, type Reflection } from "../../session-ledger/index.js";
|
|
7
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";
|
|
8
17
|
import { observationPoolMetrics } from "./pool.js";
|
|
9
18
|
export {
|
|
10
19
|
maxDropCountForPool,
|
|
@@ -12,6 +21,19 @@ export {
|
|
|
12
21
|
observationPoolMetrics,
|
|
13
22
|
} from "./pool.js";
|
|
14
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";
|
|
15
37
|
|
|
16
38
|
interface RunDropperArgs {
|
|
17
39
|
model: Model<any>;
|
|
@@ -44,6 +66,13 @@ function joinOrEmpty(items: string[]): string {
|
|
|
44
66
|
return items.length ? items.join("\n") : "(none yet)";
|
|
45
67
|
}
|
|
46
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
|
+
|
|
47
76
|
export function normalizeDropObservationIds(
|
|
48
77
|
ids: readonly string[] | undefined,
|
|
49
78
|
observations: readonly Observation[],
|
|
@@ -55,7 +84,6 @@ export function normalizeDropObservationIds(
|
|
|
55
84
|
for (const id of ids) {
|
|
56
85
|
const observation = allowed.get(id);
|
|
57
86
|
if (!observation) continue;
|
|
58
|
-
if (observation.relevance === "critical") continue;
|
|
59
87
|
if (seen.has(id)) continue;
|
|
60
88
|
seen.add(id);
|
|
61
89
|
result.push(id);
|
|
@@ -63,14 +91,21 @@ export function normalizeDropObservationIds(
|
|
|
63
91
|
return result.length > 0 ? result : undefined;
|
|
64
92
|
}
|
|
65
93
|
|
|
94
|
+
function timestampRank(timestamp: string): number {
|
|
95
|
+
const parsed = Date.parse(timestamp);
|
|
96
|
+
return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY;
|
|
97
|
+
}
|
|
98
|
+
|
|
66
99
|
export function selectDropCandidates(
|
|
67
100
|
ids: readonly string[],
|
|
68
101
|
observations: readonly Observation[],
|
|
69
102
|
maxDrops: number,
|
|
103
|
+
reflections: readonly Reflection[] = [],
|
|
70
104
|
): string[] {
|
|
71
105
|
if (maxDrops <= 0 || ids.length === 0) return [];
|
|
72
106
|
|
|
73
107
|
const byId = new Map(observations.map((observation) => [observation.id, observation]));
|
|
108
|
+
const coverageById = reflectionCoverageMap(observations, reflections);
|
|
74
109
|
const firstProposalIndex = new Map<string, number>();
|
|
75
110
|
for (let i = 0; i < ids.length; i++) {
|
|
76
111
|
const id = ids[i];
|
|
@@ -80,11 +115,14 @@ export function selectDropCandidates(
|
|
|
80
115
|
return Array.from(firstProposalIndex.entries())
|
|
81
116
|
.map(([id, index]) => ({ id, index, observation: byId.get(id) }))
|
|
82
117
|
.filter((candidate): candidate is { id: string; index: number; observation: Observation } =>
|
|
83
|
-
candidate.observation !== undefined
|
|
118
|
+
candidate.observation !== undefined
|
|
84
119
|
)
|
|
85
120
|
.sort((a, b) => {
|
|
121
|
+
const coverageDelta = REFLECTION_COVERAGE_DROP_RANK[coverageTierForObservation(a.observation, coverageById)]
|
|
122
|
+
- REFLECTION_COVERAGE_DROP_RANK[coverageTierForObservation(b.observation, coverageById)];
|
|
86
123
|
const relevanceDelta = RELEVANCE_DROP_RANK[a.observation.relevance] - RELEVANCE_DROP_RANK[b.observation.relevance];
|
|
87
|
-
|
|
124
|
+
const ageDelta = timestampRank(a.observation.timestamp) - timestampRank(b.observation.timestamp);
|
|
125
|
+
return coverageDelta || relevanceDelta || ageDelta || a.index - b.index;
|
|
88
126
|
})
|
|
89
127
|
.slice(0, maxDrops)
|
|
90
128
|
.map((candidate) => candidate.id);
|
|
@@ -96,10 +134,42 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
|
|
|
96
134
|
|
|
97
135
|
const metrics = observationPoolMetrics(observations, targetTokens);
|
|
98
136
|
const { observationTokens, fullness, tokensOverTarget, maxDropsAllowed } = metrics;
|
|
99
|
-
|
|
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
|
+
}
|
|
100
163
|
|
|
101
164
|
const proposedDropIds: string[] = [];
|
|
102
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;
|
|
103
173
|
|
|
104
174
|
const dropObservations: AgentTool<typeof DropObservationsSchema> = {
|
|
105
175
|
name: "drop_observations",
|
|
@@ -107,14 +177,51 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
|
|
|
107
177
|
description: "Propose active observation ids that are safe to remove from compacted memory.",
|
|
108
178
|
parameters: DropObservationsSchema,
|
|
109
179
|
execute: async (_id, params: DropObservationsArgs) => {
|
|
110
|
-
|
|
180
|
+
toolCallCount++;
|
|
181
|
+
rawRequestedIdsCount += params.ids.length;
|
|
182
|
+
const seenInRequest = new Set<string>();
|
|
111
183
|
let added = 0;
|
|
112
|
-
|
|
113
|
-
|
|
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
|
+
}
|
|
114
206
|
proposed.add(id);
|
|
115
207
|
proposedDropIds.push(id);
|
|
208
|
+
if (observation.relevance === "critical") {
|
|
209
|
+
criticalCandidateIdsCount++;
|
|
210
|
+
requestCriticalCandidateIds++;
|
|
211
|
+
}
|
|
116
212
|
added++;
|
|
117
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
|
+
});
|
|
118
225
|
return {
|
|
119
226
|
content: [{ type: "text", text: `Queued ${added} drop candidate${added === 1 ? "" : "s"}. Candidates this run: ${proposedDropIds.length}. Maximum drops allowed: ${maxDropsAllowed}.` }],
|
|
120
227
|
details: { added, totalCandidates: proposedDropIds.length, maxDropsAllowed },
|
|
@@ -123,7 +230,7 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
|
|
|
123
230
|
};
|
|
124
231
|
|
|
125
232
|
const fullnessPercent = Math.round(fullness * 100);
|
|
126
|
-
const userText = `CURRENT REFLECTIONS:\n${joinOrEmpty(reflections.map(reflectionToSummaryLine))}\n\nCURRENT OBSERVATIONS:\n${joinOrEmpty(observations.map(
|
|
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.`;
|
|
127
234
|
const prompts: Message[] = [{ role: "user", content: [{ type: "text", text: userText }], timestamp: Date.now() }];
|
|
128
235
|
const context: AgentContext = { systemPrompt: DROPPER_SYSTEM, messages: [], tools: [dropObservations as AgentTool<any>] };
|
|
129
236
|
const reasoning = (model as { reasoning?: unknown }).reasoning;
|
|
@@ -147,6 +254,28 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
|
|
|
147
254
|
// Tool execution collects candidate ids.
|
|
148
255
|
}
|
|
149
256
|
await stream.result();
|
|
150
|
-
const droppedIds = selectDropCandidates(proposedDropIds, observations, maxDropsAllowed);
|
|
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
|
+
});
|
|
151
280
|
return droppedIds.length > 0 ? droppedIds : undefined;
|
|
152
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
|
+
}
|
|
@@ -23,7 +23,7 @@ export function observationPoolFullness(observationTokens: number, targetTokens:
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export function droppableObservationCount(observations: readonly Observation[]): number {
|
|
26
|
-
return observations.
|
|
26
|
+
return observations.length;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export function maxDropCountForPool(observations: readonly Observation[], observationTokens: number, targetTokens: number): number {
|
|
@@ -6,25 +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 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.
|
|
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
10
|
|
|
11
11
|
What to drop, in priority order:
|
|
12
12
|
- Redundant observations whose durable meaning is already captured by current reflections with equivalent fidelity.
|
|
13
13
|
- Superseded observations where a later observation clearly replaces the older state.
|
|
14
14
|
- Repeated routine tool acknowledgements or low-signal progress updates that do not carry decisions, constraints, exact errors, or user-specific facts.
|
|
15
|
-
- Older
|
|
15
|
+
- Older observations that no longer carry working context and are covered by a reflection or a newer observation.
|
|
16
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.
|
|
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
18
|
|
|
19
|
-
|
|
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:
|
|
20
25
|
- low: consider first, but drop only when it carries no unique detail, decision, state, error, identifier, or user-specific fact.
|
|
21
26
|
- medium: drop when redundant with reflections or other observations, or when the work state is clearly obsolete.
|
|
22
27
|
- high: drop only when clearly superseded or already captured by a reflection with equivalent fidelity.
|
|
23
|
-
- 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.
|
|
24
29
|
|
|
25
|
-
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.
|
|
26
31
|
|
|
27
|
-
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:
|
|
28
33
|
- User preferences, constraints, corrections, or identity/role facts.
|
|
29
34
|
- Concrete completions that future runs must not redo.
|
|
30
35
|
- Named identifiers, file paths, function names, package names, tickets, commit SHAs, handles, or exact commands.
|
|
@@ -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.
|
|
@@ -2,12 +2,20 @@ import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } fr
|
|
|
2
2
|
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Type } from "@earendil-works/pi-ai";
|
|
4
4
|
import type { Static } from "typebox";
|
|
5
|
+
import { debugLog } from "../../debug-log.js";
|
|
5
6
|
import { hashId } from "../../ids.js";
|
|
6
7
|
import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
|
|
7
8
|
import { truncateRecordContent } from "../../serialize.js";
|
|
8
9
|
import { REFLECTOR_SYSTEM } from "./prompts.js";
|
|
9
10
|
import { estimateStringTokens } from "../../tokens.js";
|
|
10
|
-
import {
|
|
11
|
+
import { reflectionToSummaryLine, type Observation, type Reflection } from "../../session-ledger/index.js";
|
|
12
|
+
import {
|
|
13
|
+
coverageTierForObservation,
|
|
14
|
+
reflectionCoverageMap,
|
|
15
|
+
summarizeCoverageByRelevance,
|
|
16
|
+
summarizeCoverageTransitionsByRelevance,
|
|
17
|
+
type ReflectionCoverageTier,
|
|
18
|
+
} from "../dropper/coverage.js";
|
|
11
19
|
|
|
12
20
|
interface RunReflectorArgs {
|
|
13
21
|
model: Model<any>;
|
|
@@ -37,6 +45,38 @@ function joinOrEmpty(items: string[]): string {
|
|
|
37
45
|
return items.length ? items.join("\n") : "(none yet)";
|
|
38
46
|
}
|
|
39
47
|
|
|
48
|
+
export function observationToReflectorLine(
|
|
49
|
+
observation: Observation,
|
|
50
|
+
coverage: ReflectionCoverageTier,
|
|
51
|
+
): string {
|
|
52
|
+
return `[${observation.id}] ${observation.timestamp} [${observation.relevance}] [coverage: ${coverage}] ${observation.content}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function summarizeSupportIdCounts(reflections: readonly Reflection[]): {
|
|
56
|
+
reflectionCount: number;
|
|
57
|
+
totalSupportIds: number;
|
|
58
|
+
minSupportIds: number;
|
|
59
|
+
maxSupportIds: number;
|
|
60
|
+
averageSupportIds: number;
|
|
61
|
+
histogram: Record<string, number>;
|
|
62
|
+
} {
|
|
63
|
+
if (reflections.length === 0) {
|
|
64
|
+
return { reflectionCount: 0, totalSupportIds: 0, minSupportIds: 0, maxSupportIds: 0, averageSupportIds: 0, histogram: {} };
|
|
65
|
+
}
|
|
66
|
+
const counts = reflections.map((reflection) => reflection.supportingObservationIds.length);
|
|
67
|
+
const totalSupportIds = counts.reduce((sum, count) => sum + count, 0);
|
|
68
|
+
const histogram: Record<string, number> = {};
|
|
69
|
+
for (const count of counts) histogram[String(count)] = (histogram[String(count)] ?? 0) + 1;
|
|
70
|
+
return {
|
|
71
|
+
reflectionCount: reflections.length,
|
|
72
|
+
totalSupportIds,
|
|
73
|
+
minSupportIds: Math.min(...counts),
|
|
74
|
+
maxSupportIds: Math.max(...counts),
|
|
75
|
+
averageSupportIds: totalSupportIds / reflections.length,
|
|
76
|
+
histogram,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
40
80
|
export function normalizeSupportingObservationIds(
|
|
41
81
|
supportingObservationIds: readonly string[] | undefined,
|
|
42
82
|
allowedObservationIds: readonly string[],
|
|
@@ -66,9 +106,21 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
|
|
|
66
106
|
const { model, apiKey, headers, reflections, observations, signal } = args;
|
|
67
107
|
if (observations.length === 0) return undefined;
|
|
68
108
|
|
|
109
|
+
const coverageById = reflectionCoverageMap(observations, reflections);
|
|
110
|
+
debugLog("reflector.agent_start", {
|
|
111
|
+
activeObservationCount: observations.length,
|
|
112
|
+
reflectionCount: reflections.length,
|
|
113
|
+
coverageSummaryByRelevance: summarizeCoverageByRelevance(observations, coverageById),
|
|
114
|
+
});
|
|
115
|
+
|
|
69
116
|
const allowedObservationIds = observations.map((observation) => observation.id);
|
|
70
117
|
const existingReflectionIds = new Set(reflections.map((reflection) => reflection.id));
|
|
71
118
|
const accumulated = new Map<string, Reflection>();
|
|
119
|
+
let toolCallCount = 0;
|
|
120
|
+
let rawProposedReflectionCount = 0;
|
|
121
|
+
let acceptedReflectionCount = 0;
|
|
122
|
+
let duplicateReflectionCount = 0;
|
|
123
|
+
let rejectedReflectionCount = 0;
|
|
72
124
|
|
|
73
125
|
const recordReflections: AgentTool<typeof RecordReflectionsSchema> = {
|
|
74
126
|
name: "record_reflections",
|
|
@@ -76,6 +128,8 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
|
|
|
76
128
|
description: "Record new durable reflections with supporting observation ids.",
|
|
77
129
|
parameters: RecordReflectionsSchema,
|
|
78
130
|
execute: async (_id, params: RecordReflectionsArgs) => {
|
|
131
|
+
toolCallCount++;
|
|
132
|
+
rawProposedReflectionCount += params.reflections.length;
|
|
79
133
|
let added = 0;
|
|
80
134
|
let duplicates = 0;
|
|
81
135
|
let rejected = 0;
|
|
@@ -99,6 +153,9 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
|
|
|
99
153
|
});
|
|
100
154
|
added++;
|
|
101
155
|
}
|
|
156
|
+
acceptedReflectionCount += added;
|
|
157
|
+
duplicateReflectionCount += duplicates;
|
|
158
|
+
rejectedReflectionCount += rejected;
|
|
102
159
|
return {
|
|
103
160
|
content: [{ type: "text", text: `Recorded ${added} reflection${added === 1 ? "" : "s"}; ${duplicates} duplicate${duplicates === 1 ? "" : "s"}; ${rejected} rejected. Total this run: ${accumulated.size}.` }],
|
|
104
161
|
details: { added, duplicates, rejected, total: accumulated.size },
|
|
@@ -106,7 +163,7 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
|
|
|
106
163
|
},
|
|
107
164
|
};
|
|
108
165
|
|
|
109
|
-
const userText = `CURRENT REFLECTIONS:\n${joinOrEmpty(reflections.map(reflectionToSummaryLine))}\n\nCURRENT OBSERVATIONS:\n${joinOrEmpty(observations.map(
|
|
166
|
+
const userText = `CURRENT REFLECTIONS:\n${joinOrEmpty(reflections.map(reflectionToSummaryLine))}\n\nCURRENT OBSERVATIONS:\n${joinOrEmpty(observations.map((observation) => observationToReflectorLine(observation, coverageTierForObservation(observation, coverageById))))}\n\nCrystallize any missing durable facts or patterns into new reflections. If nothing is stable enough, do not call the tool.`;
|
|
110
167
|
const prompts: Message[] = [{ role: "user", content: [{ type: "text", text: userText }], timestamp: Date.now() }];
|
|
111
168
|
const context: AgentContext = { systemPrompt: REFLECTOR_SYSTEM, messages: [], tools: [recordReflections as AgentTool<any>] };
|
|
112
169
|
const reasoning = (model as { reasoning?: unknown }).reasoning;
|
|
@@ -130,5 +187,17 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
|
|
|
130
187
|
// Tool execution collects records.
|
|
131
188
|
}
|
|
132
189
|
await stream.result();
|
|
133
|
-
|
|
190
|
+
const acceptedReflections = Array.from(accumulated.values());
|
|
191
|
+
const afterCoverageById = reflectionCoverageMap(observations, [...reflections, ...acceptedReflections]);
|
|
192
|
+
debugLog("reflector.result", {
|
|
193
|
+
reason: acceptedReflections.length > 0 ? "accepted_nonempty" : toolCallCount === 0 ? "no_tool_call" : "all_filtered",
|
|
194
|
+
toolCallCount,
|
|
195
|
+
rawProposedReflectionCount,
|
|
196
|
+
acceptedReflectionCount,
|
|
197
|
+
duplicateReflectionCount,
|
|
198
|
+
rejectedReflectionCount,
|
|
199
|
+
acceptedSupportIdCounts: summarizeSupportIdCounts(acceptedReflections),
|
|
200
|
+
coverageTransitionsByRelevance: summarizeCoverageTransitionsByRelevance(observations, coverageById, afterCoverageById),
|
|
201
|
+
});
|
|
202
|
+
return acceptedReflections.length > 0 ? acceptedReflections : undefined;
|
|
134
203
|
}
|
|
@@ -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/debug-log.ts
CHANGED
|
@@ -5,10 +5,13 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
|
5
5
|
|
|
6
6
|
export const DEBUG_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
7
7
|
export const DEBUG_LOG_RELATIVE_PATH = join("observational-memory", "debug.ndjson");
|
|
8
|
+
export const DEBUG_LOG_SESSION_DIR_RELATIVE_PATH = join("observational-memory", "debug");
|
|
8
9
|
|
|
9
|
-
interface DebugLogContext {
|
|
10
|
+
export interface DebugLogContext {
|
|
10
11
|
enabled: boolean;
|
|
11
12
|
cwd?: string;
|
|
13
|
+
sessionId?: string;
|
|
14
|
+
sessionFile?: string;
|
|
12
15
|
runId?: string;
|
|
13
16
|
}
|
|
14
17
|
|
|
@@ -19,18 +22,38 @@ export function withDebugLogContext<T>(context: DebugLogContext, fn: () => T): T
|
|
|
19
22
|
return storage.run({ ...parent, ...context }, fn);
|
|
20
23
|
}
|
|
21
24
|
|
|
25
|
+
export function safeDebugLogSessionId(sessionId: string | undefined): string | undefined {
|
|
26
|
+
const trimmed = sessionId?.trim();
|
|
27
|
+
if (!trimmed) return undefined;
|
|
28
|
+
const sanitized = trimmed
|
|
29
|
+
.replace(/[^A-Za-z0-9._-]+/g, "_")
|
|
30
|
+
.replace(/^_+|_+$/g, "")
|
|
31
|
+
.slice(0, 128);
|
|
32
|
+
if (!/[A-Za-z0-9]/.test(sanitized)) return undefined;
|
|
33
|
+
return sanitized;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function debugLogRelativePath(context: Pick<DebugLogContext, "sessionId">): string {
|
|
37
|
+
const safeSessionId = safeDebugLogSessionId(context.sessionId);
|
|
38
|
+
return safeSessionId
|
|
39
|
+
? join(DEBUG_LOG_SESSION_DIR_RELATIVE_PATH, `${safeSessionId}.ndjson`)
|
|
40
|
+
: DEBUG_LOG_RELATIVE_PATH;
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
export function debugLog(event: string, data: Record<string, unknown> = {}): void {
|
|
23
44
|
const context = storage.getStore();
|
|
24
45
|
if (context?.enabled !== true) return;
|
|
25
46
|
|
|
26
47
|
try {
|
|
27
|
-
const path = join(getAgentDir(),
|
|
48
|
+
const path = join(getAgentDir(), debugLogRelativePath(context));
|
|
28
49
|
mkdirSync(dirname(path), { recursive: true });
|
|
29
50
|
rotateIfNeeded(path);
|
|
30
51
|
const payload = {
|
|
31
52
|
ts: new Date().toISOString(),
|
|
32
53
|
event,
|
|
33
54
|
cwd: context.cwd,
|
|
55
|
+
sessionId: context.sessionId,
|
|
56
|
+
sessionFile: context.sessionFile,
|
|
34
57
|
runId: context.runId,
|
|
35
58
|
data,
|
|
36
59
|
};
|
|
@@ -35,7 +35,11 @@ type ConsolidationCtx = {
|
|
|
35
35
|
ui?: { notify: (message: string, type?: "warning" | "info" | "error") => void };
|
|
36
36
|
model: unknown;
|
|
37
37
|
modelRegistry: any;
|
|
38
|
-
sessionManager: {
|
|
38
|
+
sessionManager: {
|
|
39
|
+
getBranch: () => unknown;
|
|
40
|
+
getSessionId?: () => string;
|
|
41
|
+
getSessionFile?: () => string | undefined;
|
|
42
|
+
};
|
|
39
43
|
};
|
|
40
44
|
|
|
41
45
|
type StageOutcome = "continue" | "abort";
|
|
@@ -100,6 +104,17 @@ export function registerConsolidationTrigger(pi: ExtensionAPI, runtime: Runtime)
|
|
|
100
104
|
pi.on("turn_end", launch);
|
|
101
105
|
}
|
|
102
106
|
|
|
107
|
+
function debugSessionMetadata(ctx: ConsolidationCtx): { sessionId?: string; sessionFile?: string } {
|
|
108
|
+
try {
|
|
109
|
+
return {
|
|
110
|
+
sessionId: ctx.sessionManager.getSessionId?.(),
|
|
111
|
+
sessionFile: ctx.sessionManager.getSessionFile?.(),
|
|
112
|
+
};
|
|
113
|
+
} catch {
|
|
114
|
+
return {};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
103
118
|
function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: ConsolidationCtx): void {
|
|
104
119
|
runtime.ensureConfig(ctx.cwd);
|
|
105
120
|
if (runtime.config.passive === true) return;
|
|
@@ -118,7 +133,13 @@ function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: Conso
|
|
|
118
133
|
sessionManager: ctx.sessionManager,
|
|
119
134
|
};
|
|
120
135
|
|
|
121
|
-
|
|
136
|
+
const sessionMetadata = debugSessionMetadata(ctx);
|
|
137
|
+
void runtime.launchConsolidationTask(ctx, async () => withDebugLogContext({
|
|
138
|
+
enabled: runtime.config.debugLog === true,
|
|
139
|
+
cwd: ctx.cwd,
|
|
140
|
+
...sessionMetadata,
|
|
141
|
+
runId,
|
|
142
|
+
}, async () => {
|
|
122
143
|
await runConsolidationPipeline(pi, runtime, consolidationCtx);
|
|
123
144
|
}));
|
|
124
145
|
}
|
|
@@ -221,7 +242,6 @@ async function runObserverStage(
|
|
|
221
242
|
count: observations.length,
|
|
222
243
|
observationTokens: observations.reduce((sum, observation) => sum + observation.tokenCount, 0),
|
|
223
244
|
coversUpToId,
|
|
224
|
-
observations,
|
|
225
245
|
});
|
|
226
246
|
appendEntry(pi, OM_OBSERVATIONS_RECORDED, data);
|
|
227
247
|
debugLog("observer.appended", { count: observations.length, coversUpToId });
|
|
@@ -305,6 +325,17 @@ async function runDropperStage(
|
|
|
305
325
|
});
|
|
306
326
|
return "continue";
|
|
307
327
|
}
|
|
328
|
+
debugLog("dropper.stage_start", {
|
|
329
|
+
observationCoverageId,
|
|
330
|
+
sameRunReflectionCoverageId,
|
|
331
|
+
sameRunReflectionCount: sameRunReflections.length,
|
|
332
|
+
activeObservationCount: metrics.activeObservationCount,
|
|
333
|
+
observationTokens: metrics.observationTokens,
|
|
334
|
+
targetTokens: metrics.targetTokens,
|
|
335
|
+
tokensOverTarget: metrics.tokensOverTarget,
|
|
336
|
+
fullness: metrics.fullness,
|
|
337
|
+
maxDropsAllowed: metrics.maxDropsAllowed,
|
|
338
|
+
});
|
|
308
339
|
|
|
309
340
|
if (ctx.hasUI) ctx.ui?.notify(
|
|
310
341
|
`Observational memory: dropper running after reflection — active observation pool ~${metrics.observationTokens.toLocaleString()} / ${metrics.targetTokens.toLocaleString()} target tokens (${Math.round(metrics.fullness * 100).toLocaleString()}%)`,
|
|
@@ -326,6 +357,12 @@ async function runDropperStage(
|
|
|
326
357
|
});
|
|
327
358
|
const coversUpToId = earlierCoverageMarkerId(entries, observationCoverageId, sameRunReflectionCoverageId);
|
|
328
359
|
const data = coversUpToId && droppedIds ? buildObservationsDroppedData(droppedIds, coversUpToId) : undefined;
|
|
360
|
+
debugLog("dropper.append", {
|
|
361
|
+
droppedIdsCount: droppedIds?.length ?? 0,
|
|
362
|
+
coversUpToId,
|
|
363
|
+
dataBuilt: data !== undefined,
|
|
364
|
+
appended: data !== undefined,
|
|
365
|
+
});
|
|
329
366
|
if (data) appendEntry(pi, OM_OBSERVATIONS_DROPPED, data);
|
|
330
367
|
return "continue";
|
|
331
368
|
}
|