@matthewfl/pi-contemplator 0.0.9 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +17 -11
  2. package/package.json +8 -6
  3. package/src/agents/contemplator/agent.ts +325 -91
  4. package/src/agents/contemplator/prompts.ts +6 -6
  5. package/src/agents/observer/agent.ts +14 -6
  6. package/src/agents/observer/prompts.ts +16 -7
  7. package/src/agents/reviewer/agent.ts +24 -4
  8. package/src/agents/reviewer/prompts.ts +1 -1
  9. package/src/agents/reviewer/tools.ts +24 -9
  10. package/src/agents/stream-errors.ts +1 -1
  11. package/src/agents/summarizer/agent.ts +597 -0
  12. package/src/agents/summarizer/prompts.ts +46 -0
  13. package/src/agents/summarizer/sampling.ts +80 -0
  14. package/src/commands/contemplator-view.ts +22 -1
  15. package/src/commands/settings.ts +73 -69
  16. package/src/commands/status.ts +60 -36
  17. package/src/commands/summarizer-view.ts +58 -0
  18. package/src/commands/view.ts +22 -10
  19. package/src/config.ts +25 -32
  20. package/src/hooks/compaction-hook.ts +36 -19
  21. package/src/hooks/compaction-resume.ts +4 -4
  22. package/src/hooks/compaction-trigger.ts +96 -56
  23. package/src/hooks/consolidation-trigger.ts +213 -196
  24. package/src/memory-citations.ts +37 -0
  25. package/src/required-tool-choice.ts +28 -0
  26. package/src/runtime.ts +116 -33
  27. package/src/session-ledger/fold.ts +82 -53
  28. package/src/session-ledger/index.ts +1 -0
  29. package/src/session-ledger/pools.ts +77 -0
  30. package/src/session-ledger/progress.ts +7 -18
  31. package/src/session-ledger/projection.ts +45 -177
  32. package/src/session-ledger/recall.ts +129 -127
  33. package/src/session-ledger/render-summary.ts +20 -19
  34. package/src/session-ledger/search.ts +99 -115
  35. package/src/session-ledger/types.ts +102 -75
  36. package/src/tools/compact-context.ts +1 -1
  37. package/src/tools/recall-observation.ts +99 -459
  38. package/src/tools/search-memories.ts +31 -72
  39. package/src/agents/dropper/agent.ts +0 -291
  40. package/src/agents/dropper/coverage.ts +0 -128
  41. package/src/agents/dropper/pool.ts +0 -67
  42. package/src/agents/dropper/prompts.ts +0 -48
  43. package/src/agents/reflector/agent.ts +0 -213
  44. package/src/agents/reflector/prompts.ts +0 -81
@@ -5,7 +5,7 @@ import { streamSimple } from "@earendil-works/pi-ai/compat";
5
5
  import { generateSummaryWithUsage } from "@earendil-works/pi-coding-agent";
6
6
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
7
  import { Box, Text } from "@earendil-works/pi-tui";
8
- import { agentActiveTimeMs, assistantOutputTokens, assistantToolCallCount, fullProjection, isReviewRequestEntry, isReviewResultEntry, OM_AGENT_ACTIVITY, OM_REVIEWER_MESSAGE, OM_REVIEWER_NOTICE, OM_REVIEWER_STATE, OM_REVIEW_REQUEST, OM_REVIEW_RESULT, type Entry, type ReviewResult, type StructuralReviewRequest } from "../../session-ledger/index.js";
8
+ import { agentActiveTimeMs, assistantOutputTokens, assistantToolCallCount, fullProjection, isReviewRequestEntry, isReviewResultEntry, OM_AGENT_ACTIVITY, OM_REVIEWER_MESSAGE, OM_REVIEWER_NOTICE, OM_REVIEWER_STATE, OM_REVIEW_REQUEST, OM_REVIEW_RESULT, recallMemorySources, type Entry, type ReviewResult, type StructuralReviewRequest } from "../../session-ledger/index.js";
9
9
  import { hashId } from "../../ids.js";
10
10
  import { createSearchMemoriesAgentTool } from "../../tools/search-memories.js";
11
11
  import { createRecallAgentTool } from "../../tools/recall-observation.js";
@@ -13,12 +13,14 @@ import type { MemoryUpdateCtx, Runtime } from "../../runtime.js";
13
13
  import { logAgentStreamError } from "../stream-errors.js";
14
14
  import { debugLog, withDebugLogContext } from "../../debug-log.js";
15
15
  import { boundedMaxTokens, AGENT_LOOP_MAX_TOKENS } from "../../model-budget.js";
16
+ import { forceRequiredToolPayload, requiredToolChoice } from "../../required-tool-choice.js";
17
+ import { memoryReferenceIds } from "../../memory-citations.js";
16
18
  import { buildContemplatorSystemPrompt } from "./prompts.js";
17
19
  import { runStructuralReview } from "../reviewer/agent.js";
18
20
 
19
21
  interface PendingUpdate {
20
22
  observations: string[];
21
- reflections: string[];
23
+ summaries: string[];
22
24
  reviews: string[];
23
25
  mainAgentOutputTokens: number;
24
26
  mainAgentToolCalls: number;
@@ -27,7 +29,8 @@ interface PendingUpdate {
27
29
 
28
30
  type Intervention =
29
31
  | { kind: "probe"; question: string }
30
- | { kind: "review"; request: Omit<StructuralReviewRequest, "createdAt" | "requestedBy"> };
32
+ | { kind: "review"; request: Omit<StructuralReviewRequest, "createdAt" | "requestedBy"> }
33
+ | { kind: "none" };
31
34
 
32
35
  type ReviewerSession = {
33
36
  scope: StructuralReviewRequest["scope"];
@@ -93,6 +96,7 @@ function customMessageText(content: unknown): string {
93
96
  }
94
97
 
95
98
  const AGENT_TIME_BUCKET_MINUTES = 5;
99
+ export const CONTEMPLATOR_MAX_INVOCATIONS = 3;
96
100
 
97
101
  function coarseAgentTime(durationMs: number): string {
98
102
  const totalMinutes = Math.floor(durationMs / 60_000);
@@ -109,6 +113,7 @@ const CONTEMPLATOR_STATE = "om.contemplator.state";
109
113
  const CONTEMPLATOR_SUGGESTION = "om.contemplator.suggestion";
110
114
  const REVIEW_PROPOSAL_MESSAGE = "om.review.proposal";
111
115
  const SendProbeSchema = Type.Object({ question: Type.String({ minLength: 1, description: "One concise, memory-grounded probing question, optionally preceded by one short sentence of context. Cite relevant memory identifiers." }) });
116
+ const NoInterventionSchema = Type.Object({});
112
117
  const ReviewScopeSchema = Type.Union([Type.Literal("workflow"), Type.Literal("software")]);
113
118
  export const RequestReviewSchema = Type.Object({
114
119
  scope: ReviewScopeSchema,
@@ -120,37 +125,86 @@ export const RequestReviewSchema = Type.Object({
120
125
  type SendProbeArgs = Static<typeof SendProbeSchema>;
121
126
  export type RequestReviewArgs = Static<typeof RequestReviewSchema>;
122
127
 
123
- export function createSendProbeTool(onProbe: (question: string) => boolean): AgentTool<typeof SendProbeSchema> {
128
+ type InterventionWrite = { overwritten: boolean };
129
+ type ReviewWrite = InterventionWrite & { reviewRequestId: string };
130
+
131
+ function interventionResultText(options: {
132
+ kind: "probe" | "review";
133
+ memoryIds: string[];
134
+ memoryExists: (id: string) => boolean;
135
+ overwritten: boolean;
136
+ queuedText: string;
137
+ }): string {
138
+ const replacementTool = options.kind === "probe" ? "send_probe" : "request_review";
139
+ const warnings = options.memoryIds
140
+ .filter((id) => !options.memoryExists(id))
141
+ .map((id) => `WARNING: memory ${id} not found; use search_memories and recall to find the correct memory, then call ${replacementTool} again to replace the ${options.kind} before it is sent.`);
142
+ if (options.overwritten) warnings.push("WARNING: overwriting prior probe/review tool call; only one action may be taken per turn.");
143
+ warnings.push(options.queuedText);
144
+ return warnings.join("\n");
145
+ }
146
+
147
+ export function createSendProbeTool(
148
+ onProbe: (question: string) => InterventionWrite,
149
+ memoryExists: (id: string) => boolean = () => true,
150
+ ): AgentTool<typeof SendProbeSchema> {
124
151
  return {
125
152
  name: "send_probe",
126
153
  label: "Send probe",
127
- description: "Send one concise, high-level probing question to the primary agent asynchronously. The message must contain one focused question, optionally preceded by one short sentence of context, and cite relevant memory identifiers. Do not use it for routine reminders, status updates, generic advice, direct task management, or a structural design deserving review.",
154
+ description: "Send one concise, high-level probing question to the primary agent asynchronously. The message must contain one focused question, optionally preceded by one short sentence of context, and cite relevant memory identifiers. Do not use it for routine reminders, status updates, generic advice, direct task management, or a structural design deserving review. This is a terminal tool when all cited memory ids are valid; citation warnings leave the turn open so the action can be replaced. A later intervention call in the same turn replaces this one.",
128
155
  parameters: SendProbeSchema,
129
156
  execute: async (_toolCallId, params: SendProbeArgs) => {
130
157
  const question = params.question.trim();
131
- if (!onProbe(question)) {
132
- return { content: [{ type: "text", text: "No probe was queued because this update already has an intervention. Continue without calling another intervention tool." }], details: { queued: false } };
133
- }
134
- debugLog("contemplator.tool_call", { tool: "send_probe", suggestionLength: question.length });
135
- return { content: [{ type: "text", text: "Probe queued for the primary agent's next context." }], details: { queued: true } };
158
+ const write = onProbe(question);
159
+ const memoryIds = memoryReferenceIds(question);
160
+ debugLog("contemplator.tool_call", { tool: "send_probe", suggestionLength: question.length, memoryIds, overwritten: write.overwritten });
161
+ return {
162
+ content: [{ type: "text", text: interventionResultText({ kind: "probe", memoryIds, memoryExists, overwritten: write.overwritten, queuedText: "Probe will be delivered at the end of your turn." }) }],
163
+ details: { queued: true, overwritten: write.overwritten, memoryIds },
164
+ };
165
+ },
166
+ };
167
+ }
168
+
169
+ export function createNoInterventionTool(
170
+ onNoIntervention: () => InterventionWrite,
171
+ ): AgentTool<typeof NoInterventionSchema> {
172
+ return {
173
+ name: "no_intervention",
174
+ label: "No intervention",
175
+ description: "Terminally end this contemplator update without sending anything to the primary agent. This argument-free tool is the preferred default whenever no specific, grounded, materially useful intervention is clearly warranted or usefulness is uncertain. Never send a probe merely to avoid choosing no_intervention. A later final-action call in the same turn replaces an earlier warned action.",
176
+ parameters: NoInterventionSchema,
177
+ execute: async () => {
178
+ const write = onNoIntervention();
179
+ debugLog("contemplator.no_intervention", { overwritten: write.overwritten });
180
+ const warning = write.overwritten ? "WARNING: overwriting prior probe/review/no_intervention tool call; only one final action may be taken per turn.\n" : "";
181
+ return {
182
+ content: [{ type: "text", text: `${warning}No intervention will be sent.` }],
183
+ details: { selected: true, overwritten: write.overwritten },
184
+ };
136
185
  },
137
186
  };
138
187
  }
139
188
 
140
- export function createRequestReviewTool(onReview: (request: RequestReviewArgs) => string | undefined): AgentTool<typeof RequestReviewSchema> {
189
+ export function createRequestReviewTool(
190
+ onReview: (request: RequestReviewArgs) => ReviewWrite,
191
+ memoryExists: (id: string) => boolean = () => true,
192
+ ): AgentTool<typeof RequestReviewSchema> {
141
193
  return {
142
194
  name: "request_review",
143
195
  label: "Request structural review",
144
- description: "Request a short-lived structural review grounded in cited memories. Use workflow for recurring problems in how work is performed and software for recurring problems in the product structure. Identify evidence, the suspected concern, review focus, and constraints without designing the solution.",
196
+ description: "Request a short-lived structural review grounded in cited memories. Use workflow for recurring problems in how work is performed and software for recurring problems in the product structure. Identify evidence, the suspected concern, review focus, and constraints without designing the solution. This is a terminal tool when all cited memory ids are valid; citation warnings leave the turn open so the action can be replaced. A later intervention call in the same turn replaces this one.",
145
197
  parameters: RequestReviewSchema,
146
198
  execute: async (_toolCallId, params: RequestReviewArgs) => {
147
199
  const request = { ...params, evidence: params.evidence.trim(), concern: params.concern.trim(), review_focus: params.review_focus.trim(), constraints: params.constraints?.trim() || undefined };
148
- const reviewRequestId = onReview(request);
149
- if (!reviewRequestId) {
150
- return { content: [{ type: "text", text: "No review was queued because this update already has an intervention. Continue without calling another intervention tool." }], details: { queued: false, scope: request.scope } };
151
- }
152
- debugLog("contemplator.review_requested", { reviewRequestId, scope: request.scope, evidenceLength: request.evidence.length, concernLength: request.concern.length });
153
- return { content: [{ type: "text", text: `${request.scope === "workflow" ? "Workflow" : "Software"} review queued as [${reviewRequestId}].` }], details: { queued: true, scope: request.scope, reviewRequestId } };
200
+ const write = onReview(request);
201
+ const memoryIds = memoryReferenceIds([request.evidence, request.concern, request.review_focus, request.constraints].filter((value): value is string => Boolean(value)).join("\n"));
202
+ debugLog("contemplator.review_requested", { reviewRequestId: write.reviewRequestId, scope: request.scope, evidenceLength: request.evidence.length, concernLength: request.concern.length, memoryIds, overwritten: write.overwritten });
203
+ const queuedText = `${request.scope === "workflow" ? "Workflow" : "Software"} review [${write.reviewRequestId}] will be started at the end of your turn.`;
204
+ return {
205
+ content: [{ type: "text", text: interventionResultText({ kind: "review", memoryIds, memoryExists, overwritten: write.overwritten, queuedText }) }],
206
+ details: { queued: true, overwritten: write.overwritten, scope: request.scope, reviewRequestId: write.reviewRequestId, memoryIds },
207
+ };
154
208
  },
155
209
  };
156
210
  }
@@ -160,7 +214,7 @@ export class Contemplator {
160
214
  private pending: PendingUpdate | undefined;
161
215
  private running = false;
162
216
  private seenObservationIds = new Set<string>();
163
- private seenReflectionIds = new Set<string>();
217
+ private seenSummaryIds = new Set<string>();
164
218
  private seenReviewIds = new Set<string>();
165
219
  private inFlightReviewKeys = new Set<string>();
166
220
  private inFlightReviewIds = new Set<string>();
@@ -172,7 +226,10 @@ export class Contemplator {
172
226
  private queuedProbeIds = new Set<string>();
173
227
  private sessionGeneration = 0;
174
228
  private latestCtx: MemoryUpdateCtx | undefined;
229
+ /** Completed primary-model responses since the previous contemplator run. */
175
230
  private turnsSinceRun = 0;
231
+ /** Used to avoid counting the final turn_end after its assistant message_end. */
232
+ private assistantResponsesInCurrentTurn = 0;
176
233
  private restoredTipId: string | undefined;
177
234
  /** Start of the unpersisted portion of the current main-agent run. */
178
235
  private agentActiveSince: number | undefined;
@@ -181,7 +238,12 @@ export class Contemplator {
181
238
 
182
239
  register(): void {
183
240
  this.pi.registerMessageRenderer(CONTEMPLATOR_SUGGESTION, (message, _options, theme) => {
184
- const content = customMessageText(message.content).replace(/^Background contemplator probe \(advisory\):\n?/, "");
241
+ const details = message.details as { question?: unknown } | undefined;
242
+ const content = typeof details?.question === "string"
243
+ ? details.question
244
+ : customMessageText(message.content)
245
+ .replace(/^Background contemplator probe \(advisory\):\n?/, "")
246
+ .replace(/\n\nReferenced memories can be reviewed using the recall tool\.\s*$/, "");
185
247
  const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
186
248
  box.addChild(new Text(theme.fg("thinkingHigh", `${theme.bold("◆ CONTEMPLATOR PROBE")}\n${content}`), 0, 0));
187
249
  return box;
@@ -201,12 +263,18 @@ export class Contemplator {
201
263
  });
202
264
  this.pi.on("session_start", (event: any, ctx: ExtensionContext) => {
203
265
  this.sessionGeneration++;
266
+ const generation = this.sessionGeneration;
204
267
  this.agentActiveSince = undefined;
205
268
  // AgentSession preserves its steering queue across extension reloads. An
206
269
  // undelivered tracking entry therefore still has a live queued message;
207
270
  // restoring it here would enqueue the same probe a second time.
208
271
  const reload = event?.reason === "reload";
209
272
  this.restore(ctx, true, reload, reload);
273
+ // Reconstruct and schedule durable memory immediately. In particular, a
274
+ // reload after a failed run must not silently mark its pending backlog seen.
275
+ queueMicrotask(() => {
276
+ if (generation === this.sessionGeneration) this.withDebugContext(ctx, () => this.observeTurn(ctx));
277
+ });
210
278
  });
211
279
  this.pi.on("session_tree", (_event: any, ctx: ExtensionContext) => {
212
280
  this.sessionGeneration++;
@@ -220,7 +288,7 @@ export class Contemplator {
220
288
  this.history = [];
221
289
  this.pending = undefined;
222
290
  this.seenObservationIds.clear();
223
- this.seenReflectionIds.clear();
291
+ this.seenSummaryIds.clear();
224
292
  this.seenReviewIds.clear();
225
293
  this.inFlightReviewKeys.clear();
226
294
  this.inFlightReviewIds.clear();
@@ -231,21 +299,38 @@ export class Contemplator {
231
299
  this.queuedProbeIds.clear();
232
300
  this.latestCtx = undefined;
233
301
  this.turnsSinceRun = 0;
302
+ this.assistantResponsesInCurrentTurn = 0;
234
303
  this.restoredTipId = undefined;
304
+ this.runtime.contemplatorState = {
305
+ running: false,
306
+ pendingObservations: 0,
307
+ pendingSummaries: 0,
308
+ pendingReviews: 0,
309
+ responsesSinceRun: 0,
310
+ waitingFor: "idle",
311
+ };
235
312
  });
236
313
  this.pi.on("session_compact", (_event: any, ctx: ExtensionContext) => {
237
- // The in-flight prompt is persisted by flush after its agent loop. Do not
238
- // snapshot it here or compaction would make restore replay it twice.
239
- const history = this.running ? this.history.slice(0, -1) : this.history;
240
- if (history.length > 0) {
241
- this.pi.appendEntry(CONTEMPLATOR_STATE, { version: 1, history });
314
+ // In-flight invocation messages remain local to flush until its required
315
+ // final action is selected, so history always contains only completed work.
316
+ if (this.history.length > 0) {
317
+ this.pi.appendEntry(CONTEMPLATOR_STATE, { version: 1, history: this.history });
242
318
  this.markTipPersisted(ctx);
243
- debugLog("contemplator.state_persisted", { historyMessageCount: history.length, running: this.running });
319
+ debugLog("contemplator.state_persisted", { historyMessageCount: this.history.length, running: this.running });
244
320
  }
245
321
  this.persistReviewerStates(ctx);
246
322
  });
247
- this.pi.on("message_end", (event: any) => {
323
+ this.pi.on("message_end", (event: any, ctx: ExtensionContext) => {
248
324
  const message = event?.message;
325
+ // A Pi turn can contain hours of assistant/tool/model rounds. Count each
326
+ // completed primary-model response, not only the eventual turn_end, or the
327
+ // contemplator can remain throttled forever during a long autonomous run.
328
+ if (message?.role === "assistant") {
329
+ this.persistAgentActivity(ctx);
330
+ this.turnsSinceRun++;
331
+ this.assistantResponsesInCurrentTurn++;
332
+ this.withDebugContext(ctx, () => this.observeTurn(ctx));
333
+ }
249
334
  if (message?.role !== "custom" || message.customType !== CONTEMPLATOR_SUGGESTION) return;
250
335
  if (typeof message.details?.probeId !== "string") return;
251
336
  // message_end means Pi has drained the steer into the conversation
@@ -253,6 +338,11 @@ export class Contemplator {
253
338
  // tree restore must be allowed to requeue it until context acknowledges it.
254
339
  this.queuedProbeIds.delete(message.details.probeId);
255
340
  });
341
+ this.pi.on("tool_execution_end", (_event: unknown, ctx: ExtensionContext) => {
342
+ // This records one wall-clock interval regardless of how many tools were
343
+ // running concurrently; persistAgentActivity restarts the shared clock.
344
+ this.persistAgentActivity(ctx);
345
+ });
256
346
  this.pi.on("context", (event: any, ctx: ExtensionContext) => {
257
347
  const deliveredMessages = event.messages?.filter((message: any) => message?.role === "custom" && message.customType === CONTEMPLATOR_SUGGESTION && typeof message.details?.probeId === "string") ?? [];
258
348
  for (const delivered of deliveredMessages) {
@@ -274,7 +364,10 @@ export class Contemplator {
274
364
  });
275
365
  this.pi.on("turn_end", (_event: any, ctx: ExtensionContext) => {
276
366
  this.persistAgentActivity(ctx);
277
- this.turnsSinceRun++;
367
+ // Normally message_end already counted the final assistant response. Keep a
368
+ // one-response fallback for hosts/tests that emit turn_end without it.
369
+ if (this.assistantResponsesInCurrentTurn === 0) this.turnsSinceRun++;
370
+ this.assistantResponsesInCurrentTurn = 0;
278
371
  this.withDebugContext(ctx, () => this.observeTurn(ctx));
279
372
  });
280
373
  }
@@ -287,6 +380,9 @@ export class Contemplator {
287
380
  const durationMs = Math.max(0, endedAt - startedAt);
288
381
  if (durationMs === 0) return;
289
382
  this.pi.appendEntry(OM_AGENT_ACTIVITY, { version: 1, durationMs, endedAt });
383
+ // Notify only after appendEntry so active-time schedulers always observe the
384
+ // checkpoint, regardless of Pi's ordering between independent event handlers.
385
+ this.runtime.notifyAgentActivity(ctx);
290
386
  debugLog("agent.activity_recorded", { durationMs });
291
387
  }
292
388
 
@@ -301,6 +397,22 @@ export class Contemplator {
301
397
  }, fn);
302
398
  }
303
399
 
400
+ private publishState(
401
+ waitingFor: typeof this.runtime.contemplatorState.waitingFor,
402
+ overrides: Partial<typeof this.runtime.contemplatorState> = {},
403
+ ): void {
404
+ this.runtime.contemplatorState = {
405
+ ...this.runtime.contemplatorState,
406
+ running: this.running,
407
+ pendingObservations: this.pending?.observations.length ?? 0,
408
+ pendingSummaries: this.pending?.summaries.length ?? 0,
409
+ pendingReviews: this.pending?.reviews.length ?? 0,
410
+ responsesSinceRun: this.turnsSinceRun,
411
+ waitingFor,
412
+ ...overrides,
413
+ };
414
+ }
415
+
304
416
  private restore(ctx: MemoryUpdateCtx, resetTracking = false, retainQueuedIds = false, skipUndeliveredRestore = false): void {
305
417
  this.latestCtx = ctx;
306
418
  const entries = ctx.sessionManager.getBranch() as Entry[];
@@ -308,6 +420,7 @@ export class Contemplator {
308
420
  if (this.running && !resetTracking) return;
309
421
  if (tipId === this.restoredTipId && !resetTracking) return;
310
422
  this.history = [];
423
+ let resetProjection: ReturnType<typeof fullProjection> | undefined;
311
424
  if (resetTracking) {
312
425
  this.deliveredProbeIds.clear();
313
426
  if (!retainQueuedIds) this.queuedProbeIds.clear();
@@ -315,12 +428,21 @@ export class Contemplator {
315
428
  this.resolvingReviewIds.clear();
316
429
  this.resumedReviewIds.clear();
317
430
  this.reviewerSessions.clear();
318
- const projection = fullProjection(entries);
319
- this.seenObservationIds = new Set(projection.observations.map((item) => item.id));
320
- this.seenReflectionIds = new Set(projection.reflections.map((item) => item.id));
321
- this.seenReviewIds = new Set((projection.reviews ?? []).map((item) => item.id));
431
+ resetProjection = fullProjection(entries);
432
+ this.seenObservationIds.clear();
433
+ this.seenSummaryIds.clear();
434
+ this.seenReviewIds.clear();
322
435
  this.pending = undefined;
323
436
  this.turnsSinceRun = 0;
437
+ this.assistantResponsesInCurrentTurn = 0;
438
+ this.runtime.contemplatorState = {
439
+ running: false,
440
+ pendingObservations: 0,
441
+ pendingSummaries: 0,
442
+ pendingReviews: 0,
443
+ responsesSinceRun: 0,
444
+ waitingFor: "idle",
445
+ };
324
446
  }
325
447
  const undeliveredSuggestions = new Map<string, string>();
326
448
  for (const entry of entries) {
@@ -386,6 +508,27 @@ export class Contemplator {
386
508
  }
387
509
  }
388
510
  }
511
+ if (resetTracking && resetProjection) {
512
+ // Successful contemplator update prompts are the durable coverage record.
513
+ // Only memories present in those prompts are considered seen after reload;
514
+ // memories from a failed, unpersisted run remain pending and retryable.
515
+ const coveredIds = new Set<string>();
516
+ for (const message of this.history) {
517
+ if (message.role !== "user") continue;
518
+ const text = customMessageText(message.content);
519
+ if (!text.includes("NEW MEMORY UPDATE")) continue;
520
+ for (const id of memoryReferenceIds(text)) coveredIds.add(id);
521
+ }
522
+ this.seenObservationIds = new Set(resetProjection.observations.filter((item) => coveredIds.has(item.id)).map((item) => item.id));
523
+ this.seenSummaryIds = new Set(resetProjection.summaries.filter((item) => coveredIds.has(item.id)).map((item) => item.id));
524
+ this.seenReviewIds = new Set((resetProjection.reviews ?? []).filter((item) => coveredIds.has(item.id)).map((item) => item.id));
525
+ const unprocessedObservations = resetProjection.observations.length - this.seenObservationIds.size;
526
+ const unprocessedSummaries = resetProjection.summaries.length - this.seenSummaryIds.size;
527
+ const unprocessedReviews = (resetProjection.reviews?.length ?? 0) - this.seenReviewIds.size;
528
+ if (unprocessedReviews > 0 || unprocessedObservations >= this.runtime.config.contemplatorMinNewObservations || unprocessedSummaries >= this.runtime.config.contemplatorMinNewSummaries) {
529
+ this.turnsSinceRun = this.runtime.config.contemplatorMinTurns;
530
+ }
531
+ }
389
532
  this.restoredTipId = tipId;
390
533
  for (const [probeId, question] of undeliveredSuggestions) {
391
534
  // A durable custom_message proves only that Pi inserted the probe at some
@@ -402,68 +545,75 @@ export class Contemplator {
402
545
  this.restore(ctx);
403
546
  this.runtime.ensureConfig(ctx.cwd);
404
547
  if (!this.runtime.config.contemplatorEnabled) {
548
+ this.publishState("disabled");
405
549
  debugLog("contemplator.skipped", { reason: "disabled" });
406
550
  return;
407
551
  }
408
552
  if (this.runtime.config.passive) {
553
+ this.publishState("passive");
409
554
  debugLog("contemplator.skipped", { reason: "passive" });
410
555
  return;
411
556
  }
412
557
  const branchEntries = ctx.sessionManager.getBranch() as Entry[];
413
558
  const projection = fullProjection(branchEntries);
414
559
  const observations = projection.observations.map((item) => `[${item.id}] ${item.content}`);
415
- const reflections = projection.reflections.map((item) => `[${item.id}] ${item.content}`);
560
+ const summaries = projection.summaries.map((item) => `[${item.id}] ${item.content}`);
416
561
  const reviews = projection.reviews ?? [];
417
562
  const newObservationItems = projection.observations.filter((item) => !this.seenObservationIds.has(item.id));
418
- const newReflectionItems = projection.reflections.filter((item) => !this.seenReflectionIds.has(item.id));
563
+ const newSummaryItems = projection.summaries.filter((item) => !this.seenSummaryIds.has(item.id));
419
564
  const newReviewItems = reviews.filter((item) => !this.seenReviewIds.has(item.id));
420
565
  const newObservations = newObservationItems.map((item) => `[${item.id}] ${item.content}`);
421
- const newReflections = newReflectionItems.map((item) => `[${item.id}] ${item.content}`);
566
+ const newSummaries = newSummaryItems.map((item) => `[${item.id}] ${item.content}`);
422
567
  const newReviews = newReviewItems.map(reviewSummaryLine);
423
568
  for (const item of newObservationItems) this.seenObservationIds.add(item.id);
424
- for (const item of newReflectionItems) this.seenReflectionIds.add(item.id);
569
+ for (const item of newSummaryItems) this.seenSummaryIds.add(item.id);
425
570
  for (const item of newReviewItems) this.seenReviewIds.add(item.id);
426
571
  debugLog("contemplator.update", {
427
572
  observationCount: observations.length,
428
- reflectionCount: reflections.length,
573
+ summaryCount: summaries.length,
429
574
  newObservationCount: newObservations.length,
430
- newReflectionCount: newReflections.length,
575
+ newSummaryCount: newSummaries.length,
431
576
  newReviewCount: newReviews.length,
432
577
  turnsSinceRun: this.turnsSinceRun,
433
578
  pending: this.pending !== undefined,
434
579
  running: this.running,
435
580
  });
436
- if (newObservations.length > 0 || newReflections.length > 0 || newReviews.length > 0) {
581
+ if (newObservations.length > 0 || newSummaries.length > 0 || newReviews.length > 0) {
437
582
  this.pending = {
438
583
  observations: mergeMemoryLines(this.pending?.observations ?? [], newObservations),
439
- reflections: mergeMemoryLines(this.pending?.reflections ?? [], newReflections),
584
+ summaries: mergeMemoryLines(this.pending?.summaries ?? [], newSummaries),
440
585
  reviews: mergeMemoryLines(this.pending?.reviews ?? [], newReviews),
441
586
  mainAgentOutputTokens: assistantOutputTokens(branchEntries),
442
587
  mainAgentToolCalls: assistantToolCallCount(branchEntries),
443
588
  mainAgentActiveTimeMs: agentActiveTimeMs(branchEntries),
444
589
  };
445
590
  }
446
- if (!this.pending) return;
591
+ if (!this.pending) {
592
+ this.publishState(this.running ? "running" : "idle");
593
+ return;
594
+ }
447
595
  // Activity values are cumulative send-time snapshots, not values frozen when
448
596
  // the first memory entered a pending batch. This includes work performed
449
597
  // while that batch waits for its memory/turn thresholds.
450
598
  this.pending.mainAgentOutputTokens = assistantOutputTokens(branchEntries);
451
599
  this.pending.mainAgentToolCalls = assistantToolCallCount(branchEntries);
452
600
  this.pending.mainAgentActiveTimeMs = agentActiveTimeMs(branchEntries);
453
- const enoughMemories = this.pending.reviews.length > 0 || this.pending.observations.length >= this.runtime.config.contemplatorMinNewObservations || this.pending.reflections.length >= this.runtime.config.contemplatorMinNewReflections;
601
+ const enoughMemories = this.pending.reviews.length > 0 || this.pending.observations.length >= this.runtime.config.contemplatorMinNewObservations || this.pending.summaries.length >= this.runtime.config.contemplatorMinNewSummaries;
454
602
  if (!enoughMemories || this.turnsSinceRun < this.runtime.config.contemplatorMinTurns) {
603
+ this.publishState(!enoughMemories ? "memories" : "responses");
455
604
  debugLog("contemplator.waiting", {
456
605
  enoughMemories,
457
606
  turnsSinceRun: this.turnsSinceRun,
458
607
  minTurns: this.runtime.config.contemplatorMinTurns,
459
608
  minNewObservations: this.runtime.config.contemplatorMinNewObservations,
460
- minNewReflections: this.runtime.config.contemplatorMinNewReflections,
609
+ minNewSummaries: this.runtime.config.contemplatorMinNewSummaries,
461
610
  });
462
611
  return;
463
612
  }
613
+ this.publishState(this.running ? "running" : "ready");
464
614
  debugLog("contemplator.triggered", {
465
615
  pendingObservationCount: this.pending.observations.length,
466
- pendingReflectionCount: this.pending.reflections.length,
616
+ pendingSummaryCount: this.pending.summaries.length,
467
617
  pendingReviewCount: this.pending.reviews.length,
468
618
  turnsSinceRun: this.turnsSinceRun,
469
619
  });
@@ -491,11 +641,13 @@ export class Contemplator {
491
641
  this.turnsSinceRun = 0;
492
642
  const startedAt = Date.now();
493
643
  let failed = false;
644
+ let failureMessage: string | undefined;
645
+ let workerNotified = false;
494
646
  let promptPersisted = false;
495
- let promptMessage: Message | undefined;
647
+ this.publishState("running", { lastStartedAt: startedAt, lastError: undefined });
496
648
  debugLog("contemplator.start", {
497
649
  newObservationCount: update.observations.length,
498
- newReflectionCount: update.reflections.length,
650
+ newSummaryCount: update.summaries.length,
499
651
  newReviewCount: update.reviews.length,
500
652
  historyMessageCount: this.history.length,
501
653
  });
@@ -509,12 +661,13 @@ export class Contemplator {
509
661
  });
510
662
  if (!resolved.ok) {
511
663
  failed = true;
664
+ failureMessage = resolved.reason;
512
665
  debugLog("contemplator.model_unavailable", { reason: resolved.reason });
513
666
  if (sessionGeneration === this.sessionGeneration) {
514
667
  const pending = this.pending as PendingUpdate | undefined;
515
668
  this.pending = {
516
669
  observations: mergeMemoryLines(pending?.observations ?? [], update.observations),
517
- reflections: mergeMemoryLines(pending?.reflections ?? [], update.reflections),
670
+ summaries: mergeMemoryLines(pending?.summaries ?? [], update.summaries),
518
671
  reviews: mergeMemoryLines(pending?.reviews ?? [], update.reviews),
519
672
  mainAgentOutputTokens: update.mainAgentOutputTokens,
520
673
  mainAgentToolCalls: update.mainAgentToolCalls,
@@ -534,80 +687,135 @@ export class Contemplator {
534
687
  modelId: selectedModel.id,
535
688
  contextWindow: selectedModel.contextWindow,
536
689
  });
690
+ if (this.runtime.config.showWorkerNotifications && ctx.hasUI) {
691
+ ctx.ui?.notify("pi-contemplator: contemplator running", "info");
692
+ workerNotified = true;
693
+ }
537
694
  const reviewerEnabled = this.runtime.config.reviewerEnabled;
538
695
  const updateSections: string[] = [];
539
696
  if (update.observations.length > 0) updateSections.push(`OBSERVATIONS:\n${update.observations.join("\n")}`);
540
- if (update.reflections.length > 0) updateSections.push(`REFLECTIONS:\n${update.reflections.join("\n")}`);
697
+ if (update.summaries.length > 0) updateSections.push(`SUMMARIES:\n${update.summaries.join("\n")}`);
541
698
  if (update.reviews.length > 0) updateSections.push(`REVIEWS:\n${update.reviews.join("\n")}`);
542
699
  const updateBody = updateSections.length > 0 ? updateSections.join("\n\n") : "(no new memories)";
700
+ const finalActionNames = reviewerEnabled
701
+ ? "send_probe, request_review, or no_intervention"
702
+ : "send_probe or no_intervention";
543
703
  const interventionInstruction = reviewerEnabled
544
- ? "Use send_probe for one focused question, or request_review only when a deeper workflow or software review is justified. Use no more than one intervention."
545
- : "Use send_probe only when one focused question is materially useful. Use no more than one intervention.";
704
+ ? `You must end this update by calling exactly one final-action tool: ${finalActionNames}. The tool requirement is bookkeeping, not a reason to intervene. Prefer the argument-free no_intervention whenever no specific, grounded, materially useful intervention is clearly warranted or usefulness is uncertain. Use send_probe only for one unusually useful focused question, and request_review only when a deeper workflow or software review is justified. Never send a probe merely to satisfy the final-action requirement. If a tool warns about a bad memory citation, use search_memories and recall, then call a final-action tool again to replace it.`
705
+ : `You must end this update by calling exactly one final-action tool: ${finalActionNames}. The tool requirement is bookkeeping, not a reason to intervene. Prefer the argument-free no_intervention whenever no specific, grounded, materially useful probe is clearly warranted or usefulness is uncertain. Use send_probe only for one unusually useful focused question. Never send a probe merely to satisfy the final-action requirement. If send_probe warns about a bad memory citation, use search_memories and recall, then call a final-action tool again to replace it.`;
546
706
  const prompt: Message = { role: "user", content: [{ type: "text", text: `NEW MEMORY UPDATE\n\n${updateBody}\n\nCUMULATIVE ACTIVITY: ${update.mainAgentOutputTokens} generated tokens; ${update.mainAgentToolCalls} tool calls; ${coarseAgentTime(update.mainAgentActiveTimeMs)} active.\n\nConsider these updates in the context of the accumulated memories. Prioritize reasoning gaps, contradictions, user-intent alignment, relevant overlooked alternatives, well-supported loops, and recurring structural patterns. ${interventionInstruction}` }], timestamp: Date.now() };
547
- promptMessage = prompt;
548
- this.history.push(prompt);
549
707
  let intervention: Intervention | undefined;
708
+ let finalActionWarned = false;
550
709
  const branchEntries = ctx.sessionManager.getBranch() as Entry[];
551
710
  const getBranch = () => branchEntries;
552
711
  const searchMemoriesTool = createSearchMemoriesAgentTool(getBranch);
553
712
  const recallTool = createRecallAgentTool(getBranch);
713
+ const memoryExists = (id: string) => {
714
+ const exists = recallMemorySources(branchEntries, id).status === "found";
715
+ if (!exists) finalActionWarned = true;
716
+ return exists;
717
+ };
554
718
  const sendProbe = createSendProbeTool((question) => {
555
- if (intervention) return false;
719
+ const overwritten = intervention !== undefined;
720
+ finalActionWarned = false;
556
721
  intervention = { kind: "probe", question };
557
- return true;
722
+ return { overwritten };
723
+ }, memoryExists);
724
+ const noIntervention = createNoInterventionTool(() => {
725
+ const overwritten = intervention !== undefined;
726
+ finalActionWarned = false;
727
+ intervention = { kind: "none" };
728
+ return { overwritten };
558
729
  });
559
- const tools: AgentTool<any>[] = [searchMemoriesTool as AgentTool<any>, recallTool as AgentTool<any>, sendProbe as AgentTool<any>];
730
+ const tools: AgentTool<any>[] = [searchMemoriesTool as AgentTool<any>, recallTool as AgentTool<any>, sendProbe as AgentTool<any>, noIntervention as AgentTool<any>];
560
731
  if (reviewerEnabled) {
561
732
  const requestReview = createRequestReviewTool((request) => {
562
- if (intervention) return undefined;
563
- const id = `review-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
733
+ const overwritten = intervention !== undefined;
734
+ finalActionWarned = false;
735
+ const reviewRequestId = `review-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
564
736
  intervention = { kind: "review", request: {
565
- id,
737
+ id: reviewRequestId,
566
738
  scope: request.scope,
567
739
  evidence: request.evidence,
568
740
  concern: request.concern,
569
741
  reviewFocus: request.review_focus,
570
742
  constraints: request.constraints,
571
743
  } };
572
- return id;
573
- });
744
+ return { reviewRequestId, overwritten };
745
+ }, memoryExists);
574
746
  tools.push(requestReview as AgentTool<any>);
575
747
  }
576
- const context: AgentContext = { systemPrompt: buildContemplatorSystemPrompt(reviewerEnabled), messages: this.history.slice(0, -1), tools };
577
- const config: AgentLoopConfig = {
748
+ const selectedThinkingLevel = this.runtime.config.contemplatorModel?.thinking ?? this.runtime.config.model?.thinking ?? "medium";
749
+ const supportsReasoning = (resolved.model as { reasoning?: unknown }).reasoning === true;
750
+ const config: AgentLoopConfig & { onPayload?: (payload: unknown) => unknown } = {
578
751
  model: resolved.model as Model<any>,
579
752
  apiKey: resolved.apiKey,
580
753
  headers: resolved.headers,
581
754
  maxTokens: boundedMaxTokens(resolved.model as Model<any>, AGENT_LOOP_MAX_TOKENS),
582
755
  convertToLlm: (messages) => messages as Message[],
583
756
  toolExecution: "sequential",
757
+ // A clean final-action call is the end of the contemplator turn. Do not
758
+ // spend another model request asking it to narrate after its decision.
759
+ // Citation warnings leave the loop open so it can correct the action.
760
+ shouldStopAfterTurn: () => intervention !== undefined && !finalActionWarned,
761
+ ...(supportsReasoning && selectedThinkingLevel !== "off" ? { reasoning: selectedThinkingLevel } : {}),
584
762
  };
585
- const stream = agentLoop([prompt], context, config, undefined, streamSimple);
586
- for await (const event of stream) logAgentStreamError("contemplator", event);
587
- const result = await stream.result();
588
- // The LLM call happened and was billed regardless of what we do next, so
589
- // record its usage even if the session generation changed mid-run.
590
- for (const message of result) {
591
- if (message.role === "assistant" && message.usage) {
592
- this.runtime.recordAgentUsage(message.usage);
763
+ const runMessages: AgentMessage[] = [];
764
+ let nextPrompt = prompt;
765
+ for (let invocation = 1; invocation <= CONTEMPLATOR_MAX_INVOCATIONS && !intervention; invocation++) {
766
+ runMessages.push(nextPrompt);
767
+ const context: AgentContext = { systemPrompt: buildContemplatorSystemPrompt(reviewerEnabled), messages: [...this.history, ...runMessages.slice(0, -1)], tools };
768
+ const api = (resolved.model as Model<any>).api;
769
+ const invocationConfig: AgentLoopConfig & { onPayload?: (payload: unknown) => unknown } = invocation === 1 ? config : {
770
+ ...config,
771
+ onPayload: (payload) => forceRequiredToolPayload(payload, api),
772
+ };
773
+ // SimpleStreamOptions 0.84.3 types provider-neutral choice as auto/none,
774
+ // while individual provider APIs also support required/any. Preserve the
775
+ // runtime hint and final-payload enforcement without weakening base types.
776
+ if (invocation > 1) (invocationConfig as any).toolChoice = requiredToolChoice(api);
777
+ const stream = agentLoop([nextPrompt], context, invocationConfig, undefined, streamSimple);
778
+ for await (const event of stream) logAgentStreamError("contemplator", event);
779
+ const result = await stream.result();
780
+ // agentLoop returns its input prompt as the first new message. We already
781
+ // added nextPrompt above, so do not duplicate each update in the durable
782
+ // contemplator history or in a subsequent retry's context.
783
+ const returnedMessages = result[0] === nextPrompt ? result.slice(1) : result;
784
+ runMessages.push(...returnedMessages);
785
+ // The LLM call happened and was billed regardless of what we do next.
786
+ for (const message of result) {
787
+ if (message.role === "assistant" && message.usage) this.runtime.recordAgentUsage(message.usage);
788
+ }
789
+ const assistant = [...result].reverse().find((message) => message.role === "assistant");
790
+ debugLog("contemplator.result", {
791
+ invocation,
792
+ messageCount: result.length,
793
+ assistantFound: assistant !== undefined,
794
+ assistantStopReason: assistant && "stopReason" in assistant ? assistant.stopReason : undefined,
795
+ intervention: (intervention as Intervention | undefined)?.kind,
796
+ });
797
+ if (assistant && "stopReason" in assistant && (assistant.stopReason === "error" || assistant.stopReason === "aborted")) {
798
+ const errorMessage = "errorMessage" in assistant && typeof assistant.errorMessage === "string"
799
+ ? assistant.errorMessage
800
+ : `Contemplator model ${assistant.stopReason}`;
801
+ throw new Error(errorMessage);
802
+ }
803
+ if (!intervention && invocation < CONTEMPLATOR_MAX_INVOCATIONS) {
804
+ nextPrompt = { role: "user", content: [{ type: "text", text: `You stopped without selecting a final action. If stopping meant that no intervention was clearly warranted, call the argument-free no_intervention tool now; that is the preferred default, and no explanation is required. Do not invent or send a probe merely to satisfy the tool requirement. Use send_probe only for a specific, memory-grounded question that is materially likely to improve the primary agent's reasoning${reviewerEnabled ? ", and request_review only for a well-supported recurring structural concern" : ""}. Call one final-action tool now: ${finalActionNames}. search_memories and recall do not satisfy this requirement.` }], timestamp: Date.now() };
593
805
  }
594
806
  }
595
- const assistant = [...result].reverse().find((message) => message.role === "assistant");
596
- debugLog("contemplator.result", {
597
- messageCount: result.length,
598
- assistantFound: assistant !== undefined,
599
- assistantStopReason: assistant && "stopReason" in assistant ? assistant.stopReason : undefined,
600
- intervention: intervention?.kind,
601
- });
807
+ if (!intervention) throw new Error(`Contemplator stopped ${CONTEMPLATOR_MAX_INVOCATIONS} times without calling a final-action tool`);
602
808
  if (sessionGeneration === this.sessionGeneration) {
603
- this.pi.appendEntry(CONTEMPLATOR_MESSAGE, { version: 1, message: prompt });
604
- promptPersisted = true;
605
- this.markTipPersisted(ctx);
606
- }
607
- if (assistant && sessionGeneration === this.sessionGeneration) {
608
- this.history.push(assistant);
609
- this.pi.appendEntry(CONTEMPLATOR_MESSAGE, { version: 1, message: assistant });
610
- this.markTipPersisted(ctx);
809
+ // Keep the durable contemplator history compact: prompts and assistant
810
+ // decisions are sufficient to resume its reasoning. Tool-result bodies
811
+ // are available within this run but are not copied into the ledger.
812
+ for (const message of runMessages) {
813
+ if (message.role !== "user" && message.role !== "assistant") continue;
814
+ this.history.push(message);
815
+ this.pi.appendEntry(CONTEMPLATOR_MESSAGE, { version: 1, message });
816
+ promptPersisted = true;
817
+ this.markTipPersisted(ctx);
818
+ }
611
819
  }
612
820
  if (intervention?.kind === "probe" && sessionGeneration === this.sessionGeneration) this.queueProbe(ctx, intervention.question, "send_probe");
613
821
  if (intervention?.kind === "review" && this.runtime.config.reviewerEnabled && sessionGeneration === this.sessionGeneration) {
@@ -635,13 +843,13 @@ export class Contemplator {
635
843
  if (sessionGeneration === this.sessionGeneration) await this.compactHistory(resolved.model as Model<any>, resolved.apiKey, resolved.headers, sessionGeneration);
636
844
  } catch (error) {
637
845
  failed = true;
638
- debugLog("contemplator.error", { errorMessage: error instanceof Error ? error.message : String(error) });
846
+ failureMessage = error instanceof Error ? error.message : String(error);
847
+ debugLog("contemplator.error", { errorMessage: failureMessage });
639
848
  if (sessionGeneration === this.sessionGeneration && !promptPersisted) {
640
- if (promptMessage && this.history.at(-1) === promptMessage) this.history.pop();
641
849
  const pending = this.pending as PendingUpdate | undefined;
642
850
  this.pending = {
643
851
  observations: mergeMemoryLines(pending?.observations ?? [], update.observations),
644
- reflections: mergeMemoryLines(pending?.reflections ?? [], update.reflections),
852
+ summaries: mergeMemoryLines(pending?.summaries ?? [], update.summaries),
645
853
  reviews: mergeMemoryLines(pending?.reviews ?? [], update.reviews),
646
854
  mainAgentOutputTokens: update.mainAgentOutputTokens,
647
855
  mainAgentToolCalls: update.mainAgentToolCalls,
@@ -651,11 +859,33 @@ export class Contemplator {
651
859
  }
652
860
  } finally {
653
861
  this.running = false;
862
+ const pendingHasEnoughMemories = this.pending !== undefined && (
863
+ this.pending.reviews.length > 0 ||
864
+ this.pending.observations.length >= this.runtime.config.contemplatorMinNewObservations ||
865
+ this.pending.summaries.length >= this.runtime.config.contemplatorMinNewSummaries
866
+ );
867
+ const waitingFor = !this.pending
868
+ ? "idle"
869
+ : !pendingHasEnoughMemories
870
+ ? "memories"
871
+ : this.turnsSinceRun < this.runtime.config.contemplatorMinTurns
872
+ ? "responses"
873
+ : "ready";
874
+ this.publishState(waitingFor, {
875
+ lastCompletedAt: Date.now(),
876
+ lastError: failureMessage,
877
+ });
654
878
  debugLog("contemplator.complete", {
655
879
  durationMs: Date.now() - startedAt,
656
880
  historyMessageCount: this.history.length,
657
881
  pendingUpdate: this.pending !== undefined,
658
882
  });
883
+ if (workerNotified && sessionGeneration === this.sessionGeneration) {
884
+ ctx.ui?.notify(
885
+ failed ? `pi-contemplator: contemplator failed — ${failureMessage ?? "unknown error"}` : "pi-contemplator: contemplator completed",
886
+ failed ? "warning" : "info",
887
+ );
888
+ }
659
889
  if (!failed && sessionGeneration === this.sessionGeneration && this.pending) this.observeTurn(ctx);
660
890
  }
661
891
  }
@@ -674,10 +904,14 @@ export class Contemplator {
674
904
  // triggerTurn:false as "do not queue while streaming" and inserts directly
675
905
  // into agent.state, outside the active run's context snapshot. Omitting it
676
906
  // still does not start a turn while idle, but allows steer to work in-run.
677
- if (this.agentActiveSince !== undefined) this.queuedProbeIds.add(probeId);
907
+ // Whether Pi is currently running or idle, sendMessage owns this probe in an
908
+ // in-memory steer queue until message_end drains it. Track both cases so an
909
+ // unrelated observer update or compaction callback cannot restore and enqueue
910
+ // a duplicate while the original idle steer is still pending.
911
+ this.queuedProbeIds.add(probeId);
678
912
  this.pi.sendMessage({
679
913
  customType: CONTEMPLATOR_SUGGESTION,
680
- content: `Background contemplator probe (advisory):\n${question}`,
914
+ content: `Background contemplator probe (advisory):\n${question}\n\nReferenced memories can be reviewed using the recall tool.`,
681
915
  display: this.runtime.config.showContemplatorMessages,
682
916
  details: { version: 1, question, source, probeId },
683
917
  }, { deliverAs: "steer" });