@matthewfl/pi-contemplator 0.1.0 → 0.1.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/package.json +1 -1
- package/src/agents/observer/agent.ts +83 -34
- package/src/agents/observer/prompts.ts +3 -3
- package/src/commands/settings.ts +24 -3
- package/src/config.ts +5 -5
- package/src/hooks/consolidation-trigger.ts +34 -21
- package/src/session-ledger/progress.ts +1 -1
- package/src/session-ledger/types.ts +1 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@matthewfl/pi-contemplator",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "A Pi extension that keeps long-running agentic sessions on track with background memory, contemplation, and structural review.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
1
|
+
import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage, type AgentTool } from "@earendil-works/pi-agent-core";
|
|
2
2
|
import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
3
3
|
import { Type } from "@earendil-works/pi-ai";
|
|
4
4
|
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
@@ -71,6 +71,16 @@ const RecordObservationsSchema = Type.Object({
|
|
|
71
71
|
|
|
72
72
|
type RecordObservationsArgs = Static<typeof RecordObservationsSchema>;
|
|
73
73
|
|
|
74
|
+
/** A terminal provider/agent-loop failure that must not advance observation coverage. */
|
|
75
|
+
export class ObserverStreamError extends Error {
|
|
76
|
+
readonly stopReason: string;
|
|
77
|
+
constructor(stopReason: string, errorMessage?: string) {
|
|
78
|
+
super(`observer stream ended with stopReason "${stopReason}"${errorMessage ? `: ${errorMessage}` : ""}`);
|
|
79
|
+
this.name = "ObserverStreamError";
|
|
80
|
+
this.stopReason = stopReason;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
74
84
|
function joinOrEmpty(items: string[]): string {
|
|
75
85
|
return items.length ? items.join("\n") : "(none yet)";
|
|
76
86
|
}
|
|
@@ -98,14 +108,15 @@ export async function runObserver(args: RunObserverArgs): Promise<Observation[]
|
|
|
98
108
|
if (!conversation) return undefined;
|
|
99
109
|
|
|
100
110
|
const accumulated = new Map<string, Observation>();
|
|
111
|
+
let rejectedTotal = 0;
|
|
112
|
+
let doneCalled = false;
|
|
101
113
|
|
|
102
114
|
const recordObservations: AgentTool<typeof RecordObservationsSchema> = {
|
|
103
115
|
name: "record_observations",
|
|
104
116
|
label: "Record observations",
|
|
105
117
|
description:
|
|
106
118
|
"Record a batch of new observations distilled from the conversation chunk. " +
|
|
107
|
-
"Call this multiple times as you work through the chunk
|
|
108
|
-
"then emit a short plain-text confirmation to end the run.",
|
|
119
|
+
"Call this multiple times as you work through the chunk, then call done alone when coverage is complete.",
|
|
109
120
|
parameters: RecordObservationsSchema,
|
|
110
121
|
execute: async (_id, params: RecordObservationsArgs) => {
|
|
111
122
|
let added = 0;
|
|
@@ -134,6 +145,7 @@ export async function runObserver(args: RunObserverArgs): Promise<Observation[]
|
|
|
134
145
|
});
|
|
135
146
|
added++;
|
|
136
147
|
}
|
|
148
|
+
rejectedTotal += rejected;
|
|
137
149
|
const rejectedPart = rejected > 0
|
|
138
150
|
? ` ${rejected} observation${rejected === 1 ? "" : "s"} rejected for missing or invalid sourceEntryIds.`
|
|
139
151
|
: "";
|
|
@@ -142,11 +154,22 @@ export async function runObserver(args: RunObserverArgs): Promise<Observation[]
|
|
|
142
154
|
(duplicates > 0 ? `(${duplicates} duplicate${duplicates === 1 ? "" : "s"} skipped).` : ".") +
|
|
143
155
|
rejectedPart +
|
|
144
156
|
` Total so far this run: ${accumulated.size}. ` +
|
|
145
|
-
`Continue if the chunk still has uncovered content; otherwise
|
|
157
|
+
`Continue if the chunk still has uncovered content; otherwise call done alone.`;
|
|
146
158
|
return { content: [{ type: "text", text: ack }], details: { added, duplicates, rejected, total: accumulated.size } };
|
|
147
159
|
},
|
|
148
160
|
};
|
|
149
161
|
|
|
162
|
+
const doneTool: AgentTool<any> = {
|
|
163
|
+
name: "done",
|
|
164
|
+
label: "Done",
|
|
165
|
+
description: "Confirm that the entire provided conversation chunk has been inspected and all useful new observations have been recorded. Call alone, including when there is nothing new to record.",
|
|
166
|
+
parameters: Type.Object({}),
|
|
167
|
+
execute: async () => {
|
|
168
|
+
doneCalled = true;
|
|
169
|
+
return { content: [{ type: "text", text: "Observer coverage confirmed." }], details: {}, terminate: true };
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
|
|
150
173
|
const now = nowTimestamp();
|
|
151
174
|
const userText = `Current local time: ${now}
|
|
152
175
|
|
|
@@ -156,60 +179,86 @@ ${joinOrEmpty(priorSummaries)}
|
|
|
156
179
|
CURRENT OBSERVATIONS:
|
|
157
180
|
${joinOrEmpty(priorObservations)}
|
|
158
181
|
|
|
159
|
-
Compress the following new conversation chunk into observations by calling record_observations one or more times. Do not restate facts already present in current summaries or current observations. Prefer inline conversation timestamps when assigning times; fall back to the current local time above only if no message timestamp applies.
|
|
182
|
+
Compress the following new conversation chunk into observations by calling record_observations one or more times. Do not restate facts already present in current summaries or current observations. Prefer inline conversation timestamps when assigning times; fall back to the current local time above only if no message timestamp applies. When the chunk is fully covered, call done alone. If the chunk contains no useful new information, call done without calling record_observations.
|
|
160
183
|
|
|
161
184
|
NEW CONVERSATION CHUNK:
|
|
162
185
|
${conversation}`;
|
|
163
186
|
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
timestamp: Date.now(),
|
|
169
|
-
},
|
|
170
|
-
];
|
|
171
|
-
|
|
172
|
-
const context: AgentContext = {
|
|
173
|
-
systemPrompt: OBSERVER_SYSTEM,
|
|
174
|
-
messages: [],
|
|
175
|
-
tools: [recordObservations as AgentTool<any>],
|
|
187
|
+
const initialPrompt: Message = {
|
|
188
|
+
role: "user",
|
|
189
|
+
content: [{ type: "text", text: userText }],
|
|
190
|
+
timestamp: Date.now(),
|
|
176
191
|
};
|
|
177
192
|
|
|
178
193
|
const reasoning = (model as { reasoning?: unknown }).reasoning;
|
|
179
194
|
const thinkingLevel = args.thinkingLevel ?? "low";
|
|
180
195
|
const effectiveMaxTurns = args.maxTurns && args.maxTurns > 0 ? args.maxTurns : undefined;
|
|
181
196
|
let turnCount = 0;
|
|
182
|
-
const
|
|
197
|
+
const baseConfig: AgentLoopConfig = {
|
|
183
198
|
model,
|
|
184
199
|
apiKey,
|
|
185
200
|
headers,
|
|
186
201
|
maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
|
|
187
202
|
convertToLlm: (msgs) => msgs as Message[],
|
|
188
203
|
toolExecution: "sequential",
|
|
204
|
+
shouldStopAfterTurn: () => {
|
|
205
|
+
turnCount++;
|
|
206
|
+
return doneCalled || (effectiveMaxTurns !== undefined && turnCount >= effectiveMaxTurns);
|
|
207
|
+
},
|
|
189
208
|
...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
|
|
190
|
-
...(effectiveMaxTurns !== undefined
|
|
191
|
-
? {
|
|
192
|
-
shouldStopAfterTurn: () => {
|
|
193
|
-
turnCount++;
|
|
194
|
-
return turnCount >= effectiveMaxTurns;
|
|
195
|
-
},
|
|
196
|
-
}
|
|
197
|
-
: {}),
|
|
198
209
|
};
|
|
199
210
|
|
|
200
211
|
const loop = args.agentLoop ?? agentLoop;
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
212
|
+
const history: AgentMessage[] = [];
|
|
213
|
+
let terminalFailure: { stopReason: string; errorMessage?: string } | undefined;
|
|
214
|
+
const runInvocation = async (prompt: Message): Promise<void> => {
|
|
215
|
+
const context: AgentContext = {
|
|
216
|
+
systemPrompt: OBSERVER_SYSTEM,
|
|
217
|
+
messages: history.slice(),
|
|
218
|
+
tools: [recordObservations as AgentTool<any>, doneTool],
|
|
219
|
+
};
|
|
220
|
+
const stream = loop([prompt], context, baseConfig, signal, streamSimple);
|
|
221
|
+
for await (const event of stream) {
|
|
222
|
+
logAgentStreamError("observer", event);
|
|
223
|
+
const message = (event as { message?: { role?: string; stopReason?: string; errorMessage?: string } }).message;
|
|
224
|
+
if (message?.role === "assistant" && ["error", "aborted", "length"].includes(message.stopReason ?? "")) {
|
|
225
|
+
terminalFailure = { stopReason: message.stopReason!, errorMessage: message.errorMessage };
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const result = await stream.result();
|
|
229
|
+
if (!Array.isArray(result)) return;
|
|
230
|
+
history.push(...result);
|
|
208
231
|
for (const message of result) {
|
|
209
|
-
if (message.role === "assistant" &&
|
|
232
|
+
if (message.role === "assistant" && ["error", "aborted", "length"].includes(message.stopReason ?? "")) {
|
|
233
|
+
terminalFailure = { stopReason: message.stopReason, errorMessage: message.errorMessage };
|
|
234
|
+
}
|
|
235
|
+
if (args.recordUsage && message.role === "assistant" && message.usage) args.recordUsage(message.usage);
|
|
210
236
|
}
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
await runInvocation(initialPrompt);
|
|
240
|
+
if (accumulated.size === 0 && !doneCalled && !terminalFailure && rejectedTotal === 0) {
|
|
241
|
+
const reminder: Message = {
|
|
242
|
+
role: "user",
|
|
243
|
+
content: [{ type: "text", text: `You stopped without confirming coverage. Observations recorded so far: ${accumulated.size}. If the chunk is fully covered, call done now. Otherwise call record_observations for anything still missing, then call done.` }],
|
|
244
|
+
timestamp: Date.now(),
|
|
245
|
+
};
|
|
246
|
+
await runInvocation(reminder);
|
|
211
247
|
}
|
|
212
248
|
|
|
249
|
+
// Accepted observations remain useful even if the model neglected the final
|
|
250
|
+
// confirmation. Zero-observation coverage is advanced only by an explicit
|
|
251
|
+
// done call; failures, truncation, malformed records, and repeated prose do
|
|
252
|
+
// not silently discard the source chunk.
|
|
253
|
+
if (accumulated.size === 0 && terminalFailure) {
|
|
254
|
+
throw new ObserverStreamError(terminalFailure.stopReason, terminalFailure.errorMessage);
|
|
255
|
+
}
|
|
256
|
+
if (accumulated.size === 0 && rejectedTotal > 0) {
|
|
257
|
+
throw new ObserverStreamError("invalid_observations", `${rejectedTotal} proposed observation${rejectedTotal === 1 ? " was" : "s were"} rejected`);
|
|
258
|
+
}
|
|
259
|
+
if (accumulated.size === 0 && !doneCalled) {
|
|
260
|
+
throw new ObserverStreamError("incomplete", "observer stopped twice without recording observations or calling done");
|
|
261
|
+
}
|
|
213
262
|
if (accumulated.size === 0) return undefined;
|
|
214
263
|
return Array.from(accumulated.values());
|
|
215
264
|
}
|
|
@@ -15,7 +15,7 @@ How you work:
|
|
|
15
15
|
2. Read the conversation chunk and identify what new information it contains.
|
|
16
16
|
3. Call record_observations with a batch covering part (or all) of the chunk.
|
|
17
17
|
4. Read the progress receipt. If content remains uncovered, call again. You may call the tool many times.
|
|
18
|
-
5. When the chunk is fully covered,
|
|
18
|
+
5. When the chunk is fully covered, call done alone. If there is no useful new information, call done without calling record_observations. Prose does not confirm coverage.
|
|
19
19
|
|
|
20
20
|
What to emit:
|
|
21
21
|
- Produce NEW observations for the new chunk only. Do not restate facts already present in summaries or current observations unless something has materially changed.
|
|
@@ -25,7 +25,7 @@ What to emit:
|
|
|
25
25
|
- For every observation, choose retention independently from relevance. Recording the observation correctly comes first; never skip useful evidence because retention is uncertain.
|
|
26
26
|
- Observations with missing, empty, or invalid sourceEntryIds will be rejected and not recorded, so do not call record_observations until you can cite valid source ids.
|
|
27
27
|
- Group repeated similar tool calls into a single observation rather than one per call.
|
|
28
|
-
- Skip routine, low-information events. It is fine to emit zero observations if the chunk carries no new information — in that case,
|
|
28
|
+
- Skip routine, low-information events. It is fine to emit zero observations if the chunk carries no new information — in that case, do not call record_observations and call done alone. Ignoring a chunk or replying in prose does not mark it covered.
|
|
29
29
|
|
|
30
30
|
Observation content rules:
|
|
31
31
|
|
|
@@ -125,4 +125,4 @@ A critical exact blocker can be contextual; a medium stable preference can be du
|
|
|
125
125
|
|
|
126
126
|
Timestamp format: "YYYY-MM-DD HH:MM" (local time, 24-hour, to the minute). This goes in the timestamp field, not the content.
|
|
127
127
|
|
|
128
|
-
Remember: these observations are the assistant's ONLY memory of this chunk once the raw messages fall out of context. Make them count.`;
|
|
128
|
+
Remember: these observations are the assistant's ONLY memory of this chunk once the raw messages fall out of context. Make them count. Always finish by calling done alone.`;
|
package/src/commands/settings.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { DynamicBorder, getSelectListTheme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Container, getKeybindings, Input, SelectList, Spacer, Text, fuzzyFilter, type Focusable, type SelectItem } from "@earendil-works/pi-tui";
|
|
4
|
-
import type
|
|
4
|
+
import { OBSERVER_CHUNK_CONTEXT_RATIO, resolveObserverChunkMaxTokens, type ConfiguredModel } from "../config.js";
|
|
5
5
|
import { OM_SETTINGS, type Runtime, type SessionSettings } from "../runtime.js";
|
|
6
6
|
|
|
7
7
|
type ModelRegistryLike = {
|
|
8
8
|
refresh?(): Promise<void>;
|
|
9
9
|
getAvailable(): Array<{ provider: string; id: string }>;
|
|
10
10
|
getAll(): Array<{ provider: string; id: string }>;
|
|
11
|
+
find?(provider: string, id: string): { contextWindow?: number } | undefined;
|
|
11
12
|
};
|
|
12
13
|
type NumberSetting = "observeAfterTokens" | "compactAfterTokens" | "observerChunkMaxTokens" | "newMemoryPoolMaxTokens" | "oldMemoryPoolTargetTokens" | "agentMaxTurns" | "contemplatorMinNewObservations" | "contemplatorMinNewSummaries" | "contemplatorMinTurns" | "summarizerRetriggerTokens" | "summarizerSamplingThresholdTokens";
|
|
13
14
|
type BooleanSetting = "contemplatorEnabled" | "showContemplatorMessages" | "reviewerEnabled" | "summarizerEnabled" | "compactionObserverEnabled" | "showWorkerNotifications" | "passive" | "debugLog";
|
|
@@ -44,6 +45,26 @@ function extensionEnabledLabel(runtime: Runtime): string {
|
|
|
44
45
|
return hasOverride(runtime.getSessionSettings(), "passive") ? String(enabled) : `${enabled} (default)`;
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
export function observerInputCapLabel(runtime: Runtime, contextWindow: number | undefined): string {
|
|
49
|
+
const explicit = runtime.config.observerChunkMaxTokens;
|
|
50
|
+
if (explicit !== undefined) {
|
|
51
|
+
return `${explicit.toLocaleString()} tokens${hasOverride(runtime.getSessionSettings(), "observerChunkMaxTokens") ? "" : " (default)"}`;
|
|
52
|
+
}
|
|
53
|
+
const cap = resolveObserverChunkMaxTokens(runtime.config, contextWindow);
|
|
54
|
+
if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) {
|
|
55
|
+
const percent = OBSERVER_CHUNK_CONTEXT_RATIO * 100;
|
|
56
|
+
return `${percent}% of ${contextWindow.toLocaleString()} = ${cap.toLocaleString()} tokens (derived default)`;
|
|
57
|
+
}
|
|
58
|
+
return `${cap.toLocaleString()} tokens (fallback default; model context unavailable)`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function observerContextWindow(runtime: Runtime, ctx: ExtensionContext): number | undefined {
|
|
62
|
+
const configured = runtime.config.model;
|
|
63
|
+
const registry = ctx.modelRegistry as unknown as ModelRegistryLike;
|
|
64
|
+
const model = configured ? registry.find?.(configured.provider, configured.id) ?? ctx.model : ctx.model;
|
|
65
|
+
return (model as { contextWindow?: number } | undefined)?.contextWindow;
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
interface ModelOption extends SelectItem {
|
|
48
69
|
configuredModel: ConfiguredModel | null;
|
|
49
70
|
}
|
|
@@ -207,7 +228,7 @@ export function registerSettingsCommand(pi: ExtensionAPI, runtime: Runtime): voi
|
|
|
207
228
|
`Observe source during compaction: ${scalarLabel(runtime, "compactionObserverEnabled")}`,
|
|
208
229
|
`Observer and summarizer model: ${hasOverride(settings, "model") ? modelLabel(runtime.config.model) : `${modelLabel(runtime.getDefaultConfig().model)} (default)`}`,
|
|
209
230
|
`Observer source backlog trigger (tokens): ${scalarLabel(runtime, "observeAfterTokens")}`,
|
|
210
|
-
`Observer input cap
|
|
231
|
+
`Observer input cap: ${observerInputCapLabel(runtime, observerContextWindow(runtime, ctx))}`,
|
|
211
232
|
`Observer and summarizer max rounds: ${scalarLabel(runtime, "agentMaxTurns")}`,
|
|
212
233
|
`Automatic compaction source backlog trigger (tokens): ${scalarLabel(runtime, "compactAfterTokens")}`,
|
|
213
234
|
`Automatic compaction threshold mode: ${hasOverride(settings, "compactAfterTokensMode") ? runtime.config.compactAfterTokensMode : `${runtime.getDefaultConfig().compactAfterTokensMode} (default)`}`,
|
|
@@ -245,7 +266,7 @@ export function registerSettingsCommand(pi: ExtensionAPI, runtime: Runtime): voi
|
|
|
245
266
|
} else {
|
|
246
267
|
const numberChoice: Array<[string, NumberSetting, string]> = [
|
|
247
268
|
["Observer source backlog trigger (tokens):", "observeAfterTokens", "Observer source backlog trigger (tokens)"],
|
|
248
|
-
["Observer input cap
|
|
269
|
+
["Observer input cap:", "observerChunkMaxTokens", "Observer input cap (tokens)"],
|
|
249
270
|
["Observer and summarizer max rounds:", "agentMaxTurns", "Observer and summarizer max rounds"],
|
|
250
271
|
["Automatic compaction source backlog trigger (tokens):", "compactAfterTokens", "Automatic compaction source backlog trigger (tokens)"],
|
|
251
272
|
["New memory pool protection budget (tokens):", "newMemoryPoolMaxTokens", "New memory pool protection budget (tokens)"],
|
package/src/config.ts
CHANGED
|
@@ -125,11 +125,11 @@ export const OBSERVER_CHUNK_MIN_TOKENS = 256;
|
|
|
125
125
|
/**
|
|
126
126
|
* Fraction of the memory model's context window used for the derived observer
|
|
127
127
|
* chunk cap. Chunk sizes are estimated at ~4 chars/token, which can undercount
|
|
128
|
-
* real tokens
|
|
129
|
-
*
|
|
130
|
-
*
|
|
128
|
+
* real tokens substantially on non-ASCII content. The estimator remains
|
|
129
|
+
* approximate; the remaining 75% of the advertised window accommodates injected memory, the
|
|
130
|
+
* system prompt, and model output.
|
|
131
131
|
*/
|
|
132
|
-
export const OBSERVER_CHUNK_CONTEXT_RATIO = 0.
|
|
132
|
+
export const OBSERVER_CHUNK_CONTEXT_RATIO = 0.25;
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
135
|
* Resolve the maximum estimated tokens the observer serializes into one chunk.
|
|
@@ -143,7 +143,7 @@ export const OBSERVER_CHUNK_CONTEXT_RATIO = 0.2;
|
|
|
143
143
|
* after repeated observer failures, or when the extension is enabled mid-way
|
|
144
144
|
* into a long session) makes every observer call fail, so coverage never
|
|
145
145
|
* advances and the session can never recover. With the cap, oversized backlogs
|
|
146
|
-
* are drained oldest-first across successive
|
|
146
|
+
* are drained oldest-first across successive bounded passes.
|
|
147
147
|
*/
|
|
148
148
|
export function resolveObserverChunkMaxTokens(config: Config, contextWindow: number | undefined): number {
|
|
149
149
|
if (config.observerChunkMaxTokens !== undefined && config.observerChunkMaxTokens > 0) {
|
|
@@ -227,12 +227,27 @@ export async function runConsolidationPipeline(
|
|
|
227
227
|
runtime.consolidationPhase = "observer";
|
|
228
228
|
runtime.lastObserverStartedAt = Date.now();
|
|
229
229
|
try {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
230
|
+
// A large backlog is drained in bounded, oldest-first chunks. The normal
|
|
231
|
+
// trigger threshold controls when the batch stops; a static compaction
|
|
232
|
+
// snapshot is intentionally processed only once. Coverage must advance on
|
|
233
|
+
// every iteration, otherwise stop rather than spin on a failed chunk.
|
|
234
|
+
while (true) {
|
|
235
|
+
const beforeEntries = ctx.sessionManager.getBranch() as Entry[];
|
|
236
|
+
const beforeCoverage = latestCoverageIndex(beforeEntries, OM_OBSERVATIONS_RECORDED);
|
|
237
|
+
const observerOutcome = await runObserverStage(pi, runtime, ctx, resolveModel, {
|
|
238
|
+
force: options.forceObserver === true,
|
|
239
|
+
entries: options.observerEntries,
|
|
240
|
+
contextGeneration,
|
|
241
|
+
});
|
|
242
|
+
if (observerOutcome === "abort") return;
|
|
243
|
+
if (options.observerEntries) break;
|
|
244
|
+
|
|
245
|
+
const afterEntries = ctx.sessionManager.getBranch() as Entry[];
|
|
246
|
+
const afterCoverage = latestCoverageIndex(afterEntries, OM_OBSERVATIONS_RECORDED);
|
|
247
|
+
const remainingTokens = rawTokensSinceObservationCoverage(afterEntries);
|
|
248
|
+
if (afterCoverage <= beforeCoverage || remainingTokens < runtime.config.observeAfterTokens) break;
|
|
249
|
+
debugLog("observer.backlog_continue", { remainingTokens, afterCoverage });
|
|
250
|
+
}
|
|
236
251
|
} catch (error) {
|
|
237
252
|
debugLog("observer.error", { errorMessage: runtime.recordConsolidationStageError(ctx, "observer", error) });
|
|
238
253
|
return;
|
|
@@ -465,15 +480,6 @@ async function runObserverStage(
|
|
|
465
480
|
debugLog("observer.stale", { reason: "session_or_branch_changed" });
|
|
466
481
|
return "abort";
|
|
467
482
|
}
|
|
468
|
-
if (!observations || observations.length === 0) {
|
|
469
|
-
debugLog("observer.empty", { coversUpToId });
|
|
470
|
-
if (ctx.hasUI) ctx.ui?.notify(
|
|
471
|
-
"pi-contemplator: observer returned no observations",
|
|
472
|
-
"warning",
|
|
473
|
-
);
|
|
474
|
-
return "continue";
|
|
475
|
-
}
|
|
476
|
-
|
|
477
483
|
const currentEntries = ctx.sessionManager.getBranch() as Entry[];
|
|
478
484
|
let effectiveCoversUpToId = coversUpToId;
|
|
479
485
|
if (!currentEntries.some((entry) => entry.id === coversUpToId)) {
|
|
@@ -488,17 +494,24 @@ async function runObserverStage(
|
|
|
488
494
|
compactionId: compaction?.id,
|
|
489
495
|
});
|
|
490
496
|
}
|
|
491
|
-
const
|
|
497
|
+
const accepted = observations ?? [];
|
|
498
|
+
const data = buildObservationsRecordedData(accepted, effectiveCoversUpToId);
|
|
492
499
|
if (!data) return "continue";
|
|
493
|
-
debugLog("observer.records", {
|
|
494
|
-
count:
|
|
495
|
-
observationTokens:
|
|
500
|
+
debugLog(accepted.length > 0 ? "observer.records" : "observer.coverage_only", {
|
|
501
|
+
count: accepted.length,
|
|
502
|
+
observationTokens: accepted.reduce((sum, observation) => sum + observation.tokenCount, 0),
|
|
496
503
|
coversUpToId: effectiveCoversUpToId,
|
|
497
504
|
});
|
|
505
|
+
// A clean zero-observation verdict is still successful coverage. Persist an
|
|
506
|
+
// empty batch so the next bounded pass starts after this chunk instead of
|
|
507
|
+
// retrying the same low-information source forever. Failures throw above and
|
|
508
|
+
// therefore never reach this coverage commit.
|
|
498
509
|
appendEntry(pi, OM_OBSERVATIONS_RECORDED, data);
|
|
499
|
-
debugLog("observer.appended", { count:
|
|
510
|
+
debugLog("observer.appended", { count: accepted.length, coversUpToId: effectiveCoversUpToId });
|
|
500
511
|
if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
|
|
501
|
-
|
|
512
|
+
accepted.length > 0
|
|
513
|
+
? `pi-contemplator: ${accepted.length} observation${accepted.length === 1 ? "" : "s"} recorded`
|
|
514
|
+
: "pi-contemplator: observer found no new information; processed chunk marked covered",
|
|
502
515
|
"info",
|
|
503
516
|
);
|
|
504
517
|
return "continue";
|
|
@@ -74,7 +74,7 @@ function isNonEmptyArray(value: unknown): value is unknown[] {
|
|
|
74
74
|
function isValidCoverageEntry(entry: Entry, customType: MemoryCoverageCustomType): entry is Entry & { data: { coversUpToId: string } } {
|
|
75
75
|
if (entry.type !== "custom" || entry.customType !== customType) return false;
|
|
76
76
|
if (!isObject(entry.data) || typeof entry.data.coversUpToId !== "string") return false;
|
|
77
|
-
if (customType === OM_OBSERVATIONS_RECORDED) return
|
|
77
|
+
if (customType === OM_OBSERVATIONS_RECORDED) return Array.isArray(entry.data.observations);
|
|
78
78
|
return customType === OM_SUMMARIZER_COMMIT && isNonEmptyArray(entry.data.summaries);
|
|
79
79
|
}
|
|
80
80
|
|
|
@@ -235,7 +235,6 @@ export function isObservationsRecordedData(value: unknown): value is Observation
|
|
|
235
235
|
if (!isPlainRecord(value)) return false;
|
|
236
236
|
return (
|
|
237
237
|
Array.isArray(value.observations) &&
|
|
238
|
-
value.observations.length > 0 &&
|
|
239
238
|
value.observations.every(isObservation) &&
|
|
240
239
|
isNonEmptyString(value.coversUpToId)
|
|
241
240
|
);
|
|
@@ -345,7 +344,7 @@ export function buildObservationsRecordedData(
|
|
|
345
344
|
observations: Observation[],
|
|
346
345
|
coversUpToId: string,
|
|
347
346
|
): ObservationsRecordedEntryData | undefined {
|
|
348
|
-
if (
|
|
347
|
+
if (!isNonEmptyString(coversUpToId)) return undefined;
|
|
349
348
|
const candidate = { observations, coversUpToId };
|
|
350
349
|
return isObservationsRecordedData(candidate) ? candidate : undefined;
|
|
351
350
|
}
|