@matthewfl/pi-contemplator 0.0.6 → 0.0.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthewfl/pi-contemplator",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
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",
@@ -244,11 +244,24 @@ export class Contemplator {
244
244
  }
245
245
  this.persistReviewerStates(ctx);
246
246
  });
247
+ this.pi.on("message_end", (event: any) => {
248
+ const message = event?.message;
249
+ if (message?.role !== "custom" || message.customType !== CONTEMPLATOR_SUGGESTION) return;
250
+ if (typeof message.details?.probeId !== "string") return;
251
+ // message_end means Pi has drained the steer into the conversation
252
+ // stream. It is no longer protected by an in-memory queue, so a later
253
+ // tree restore must be allowed to requeue it until context acknowledges it.
254
+ this.queuedProbeIds.delete(message.details.probeId);
255
+ });
247
256
  this.pi.on("context", (event: any, ctx: ExtensionContext) => {
248
257
  const deliveredMessages = event.messages?.filter((message: any) => message?.role === "custom" && message.customType === CONTEMPLATOR_SUGGESTION && typeof message.details?.probeId === "string") ?? [];
249
258
  for (const delivered of deliveredMessages) {
250
259
  if (this.deliveredProbeIds.has(delivered.details.probeId)) continue;
251
260
  this.deliveredProbeIds.add(delivered.details.probeId);
261
+ // Once Pi includes the probe in a provider context it is no longer in
262
+ // either in-memory delivery queue. Keeping this id indefinitely caused
263
+ // later tree restores to suppress a genuinely needed requeue.
264
+ this.queuedProbeIds.delete(delivered.details.probeId);
252
265
  this.pi.appendEntry(CONTEMPLATOR_SUGGESTION, {
253
266
  version: 1,
254
267
  suggestion: typeof delivered.details.question === "string" ? delivered.details.question : String(delivered.content ?? ""),
@@ -310,12 +323,7 @@ export class Contemplator {
310
323
  this.turnsSinceRun = 0;
311
324
  }
312
325
  const undeliveredSuggestions = new Map<string, string>();
313
- const queuedProbeIds = new Set<string>();
314
326
  for (const entry of entries) {
315
- if (entry.customType === CONTEMPLATOR_SUGGESTION && entry.type === "custom_message") {
316
- const details = entry.details as { probeId?: unknown } | undefined;
317
- if (typeof details?.probeId === "string") queuedProbeIds.add(details.probeId);
318
- }
319
327
  if (entry.customType === CONTEMPLATOR_STATE && entry.data && typeof entry.data === "object") {
320
328
  const state = entry.data as { history?: unknown };
321
329
  if (Array.isArray(state.history)) this.history = state.history.filter((message): message is AgentMessage => !!message && typeof message === "object");
@@ -380,7 +388,11 @@ export class Contemplator {
380
388
  }
381
389
  this.restoredTipId = tipId;
382
390
  for (const [probeId, question] of undeliveredSuggestions) {
383
- if (skipUndeliveredRestore || queuedProbeIds.has(probeId) || this.queuedProbeIds.has(probeId)) continue;
391
+ // A durable custom_message proves only that Pi inserted the probe at some
392
+ // point; it does not prove an in-memory queue still owns it, and compaction
393
+ // may have removed it from active model context. Suppress requeue only for
394
+ // ids this live extension instance still knows are queued.
395
+ if (skipUndeliveredRestore || this.queuedProbeIds.has(probeId)) continue;
384
396
  this.queueProbe(ctx, question, "restore", probeId);
385
397
  }
386
398
  if (resetTracking) void this.resumePendingReviews(ctx);
@@ -650,18 +662,22 @@ export class Contemplator {
650
662
 
651
663
  private queueProbe(ctx: MemoryUpdateCtx, question: string, source: "send_probe" | "restore", existingProbeId?: string): void {
652
664
  const probeId = existingProbeId ?? `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
665
+ // Persist intent before touching Pi's in-memory queue. A crash in between
666
+ // leaves a recoverable pending probe rather than an invisible lost one.
667
+ this.pi.appendEntry(CONTEMPLATOR_SUGGESTION, { version: 1, suggestion: question, delivered: false, source, probeId });
668
+ this.markTipPersisted(ctx);
669
+ // DELIVERY INVARIANT: probes must always use steer. The contemplator is
670
+ // designed for agents that run for hours; a probe must be injected after
671
+ // the current tool-call batch, before the very next model request. Never
672
+ // change this to nextTurn: that can postpone delivery until a user prompt.
673
+ // triggerTurn stays false so a probe never starts an extra agent turn.
674
+ if (this.agentActiveSince !== undefined) this.queuedProbeIds.add(probeId);
653
675
  this.pi.sendMessage({
654
676
  customType: CONTEMPLATOR_SUGGESTION,
655
677
  content: `Background contemplator probe (advisory):\n${question}`,
656
678
  display: this.runtime.config.showContemplatorMessages,
657
679
  details: { version: 1, question, source, probeId },
658
680
  }, { deliverAs: "steer", triggerTurn: false });
659
- // sendMessage queues synchronously. Mark every source (not only restore)
660
- // before a later turn_end can rebuild state from the still-undelivered
661
- // tracking entry and enqueue this probe again.
662
- this.queuedProbeIds.add(probeId);
663
- this.pi.appendEntry(CONTEMPLATOR_SUGGESTION, { version: 1, suggestion: question, delivered: false, source, probeId });
664
- this.markTipPersisted(ctx);
665
681
  debugLog("contemplator.suggestion_queued", {
666
682
  probeId,
667
683
  suggestionLength: question.length,
@@ -30,13 +30,22 @@ function isCurrentWatch(runtime: Runtime, generation: number): boolean {
30
30
  return runtime.compactionResumePending && runtime.compactionResumeGeneration === generation;
31
31
  }
32
32
 
33
- function sendResumeMessage(pi: ExtensionAPI, ctx: ResumeCtx, afterFailure: boolean): void {
33
+ function continuationMessage(afterFailure: boolean, shortContinuationPrompt?: string): string {
34
+ if (shortContinuationPrompt) {
35
+ return afterFailure
36
+ ? `Context compaction failed. Continue with these instructions:\n\n${shortContinuationPrompt}`
37
+ : shortContinuationPrompt;
38
+ }
39
+ return afterFailure
40
+ ? "Context compaction failed. Continue the current task without waiting for another user message."
41
+ : "Continue the current task from the compacted context without waiting for another user message.";
42
+ }
43
+
44
+ function sendResumeMessage(pi: ExtensionAPI, ctx: ResumeCtx, afterFailure: boolean, shortContinuationPrompt?: string): void {
34
45
  try {
35
46
  pi.sendMessage({
36
47
  customType: "om.compaction.resume",
37
- content: afterFailure
38
- ? "Context compaction failed. Continue the current task without waiting for another user message."
39
- : "Continue the current task from the compacted context without waiting for another user message.",
48
+ content: continuationMessage(afterFailure, shortContinuationPrompt),
40
49
  display: false,
41
50
  }, {
42
51
  deliverAs: "followUp",
@@ -54,6 +63,7 @@ function scheduleResumeRetries(
54
63
  ctx: ResumeCtx,
55
64
  generation: number,
56
65
  afterFailure: boolean,
66
+ shortContinuationPrompt: string | undefined,
57
67
  retryIndex = 0,
58
68
  ): void {
59
69
  if (!isCurrentWatch(runtime, generation)) return;
@@ -75,8 +85,8 @@ function scheduleResumeRetries(
75
85
  `Observational memory: continuation did not start; retrying (${retryIndex + 1}/${RESUME_RETRY_DELAYS_MS.length})`,
76
86
  "warning",
77
87
  );
78
- sendResumeMessage(pi, ctx, afterFailure);
79
- scheduleResumeRetries(pi, runtime, ctx, generation, afterFailure, retryIndex + 1);
88
+ sendResumeMessage(pi, ctx, afterFailure, shortContinuationPrompt);
89
+ scheduleResumeRetries(pi, runtime, ctx, generation, afterFailure, shortContinuationPrompt, retryIndex + 1);
80
90
  }, RESUME_RETRY_DELAYS_MS[retryIndex]);
81
91
  }
82
92
 
@@ -86,10 +96,11 @@ export function resumeAfterCompaction(
86
96
  runtime: Runtime,
87
97
  ctx: ResumeCtx,
88
98
  afterFailure = false,
99
+ shortContinuationPrompt?: string,
89
100
  ): void {
90
101
  const generation = beginResumeWatch(runtime);
91
- sendResumeMessage(pi, ctx, afterFailure);
92
- scheduleResumeRetries(pi, runtime, ctx, generation, afterFailure);
102
+ sendResumeMessage(pi, ctx, afterFailure, shortContinuationPrompt);
103
+ scheduleResumeRetries(pi, runtime, ctx, generation, afterFailure, shortContinuationPrompt);
93
104
  }
94
105
 
95
106
  /**
@@ -111,7 +122,7 @@ export function watchForNativeCompactionResume(
111
122
  "warning",
112
123
  );
113
124
  sendResumeMessage(pi, ctx, false);
114
- scheduleResumeRetries(pi, runtime, ctx, generation, false);
125
+ scheduleResumeRetries(pi, runtime, ctx, generation, false, undefined);
115
126
  }, NATIVE_RESUME_GRACE_MS);
116
127
  }
117
128
 
@@ -17,6 +17,7 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
17
17
  if (runtime.compactInFlight) return;
18
18
 
19
19
  const agentRequested = runtime.compactRequested;
20
+ const shortContinuationPrompt = agentRequested ? runtime.compactContinuationPrompt : undefined;
20
21
  if (agentRequested) runtime.compactRequested = false;
21
22
  else if (runtime.config.passive === true) return;
22
23
 
@@ -88,6 +89,7 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
88
89
  "info",
89
90
  );
90
91
  }
92
+ if (agentRequested) runtime.compactContinuationPrompt = undefined;
91
93
  ctx.compact({
92
94
  onComplete: () => {
93
95
  runtime.compactInFlight = false;
@@ -95,7 +97,7 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
95
97
  // Both explicit and proactive OM compactions are manual from Pi's
96
98
  // perspective (willRetry=false), so Pi will not continue either one.
97
99
  // Always enqueue a hidden continuation after OM finishes compacting.
98
- resumeAfterCompaction(pi, runtime, { hasUI, ui });
100
+ resumeAfterCompaction(pi, runtime, { hasUI, ui }, false, shortContinuationPrompt);
99
101
  },
100
102
  onError: (error: { message: string }) => {
101
103
  runtime.compactInFlight = false;
@@ -104,18 +106,19 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
104
106
  if (error.message !== "Compaction cancelled" && hasUI) {
105
107
  ui?.notify(`Observational memory: ${error.message}`, "error");
106
108
  }
107
- resumeAfterCompaction(pi, runtime, { hasUI, ui }, true);
109
+ resumeAfterCompaction(pi, runtime, { hasUI, ui }, true, shortContinuationPrompt);
108
110
  },
109
111
  });
110
112
  } catch (error) {
111
113
  runtime.compactInFlight = false;
114
+ if (agentRequested) runtime.compactContinuationPrompt = undefined;
112
115
  runtime.compactOrigin = undefined;
113
116
  const msg = error instanceof Error ? error.message : String(error);
114
117
  if (hasUI) {
115
118
  ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
116
119
  ui?.notify(`Observational memory: compact threw: ${msg}`, "error");
117
120
  }
118
- resumeAfterCompaction(pi, runtime, { hasUI, ui }, true);
121
+ resumeAfterCompaction(pi, runtime, { hasUI, ui }, true, shortContinuationPrompt);
119
122
  }
120
123
  }, 0);
121
124
  });
package/src/runtime.ts CHANGED
@@ -145,6 +145,8 @@ export class Runtime {
145
145
  consolidationPhase: ConsolidationPhase | undefined;
146
146
  compactInFlight = false;
147
147
  compactRequested = false;
148
+ /** Agent-authored instructions to deliver after an explicit compact_context request. */
149
+ compactContinuationPrompt: string | undefined;
148
150
  compactOrigin: "proactive" | "agent-requested" | undefined;
149
151
  compactHookInFlight = false;
150
152
  compactionResumePending = false;
@@ -212,6 +214,7 @@ export class Runtime {
212
214
  // never-cleared compactInFlight bricking all future compactions).
213
215
  this.compactInFlight = false;
214
216
  this.compactRequested = false;
217
+ this.compactContinuationPrompt = undefined;
215
218
  this.compactOrigin = undefined;
216
219
  this.compactionResumePending = false;
217
220
  this.compactionResumeGeneration += 1;
@@ -4,7 +4,7 @@ import type { Runtime } from "../runtime.js";
4
4
 
5
5
  export const COMPACT_CONTEXT_TOOL_NAME = "compact_context";
6
6
  export const COMPACT_CONTEXT_DESCRIPTION =
7
- "Force manual compaction. Use sparingly when substantial work remains but the remaining context is insufficient, or when accumulated context has become noisy or stale enough to impair focus and reliable reasoning.";
7
+ "Force manual compaction and resume with agent-authored next-step instructions. Use sparingly when substantial work remains but the remaining context is insufficient, or when accumulated context has become noisy or stale enough to impair focus and reliable reasoning.";
8
8
 
9
9
  export type CompactContextDetails = {
10
10
  status: "scheduled" | "already_pending" | "in_progress";
@@ -20,10 +20,17 @@ export function createCompactContextTool(runtime: Runtime) {
20
20
  promptGuidelines: [
21
21
  "Use compact_context sparingly when either substantial additional work remains and there is not enough context left to complete it, or accumulated past context has become noisy, stale, or distracting enough that you are struggling to focus on the current task or reason about it reliably.",
22
22
  "Do not use compact_context routinely, for short tasks, or merely because the conversation is long; use it for genuine context-capacity pressure or context degradation that is interfering with the work.",
23
- "Call compact_context by itself and stop the current turn; observational memory will compact the context and automatically resume the task.",
23
+ "Call compact_context by itself, provide short_continuation_prompt with concrete instructions for the next agent step, and stop the current turn; observational memory will compact the context and automatically resume with those instructions.",
24
24
  ],
25
- parameters: Type.Object({}),
26
- async execute() {
25
+ parameters: Type.Object({
26
+ short_continuation_prompt: Type.String({
27
+ minLength: 1,
28
+ maxLength: 1_000,
29
+ pattern: "\\S",
30
+ description: "Short, concrete instructions to your post-compaction self describing the next action and any critical immediate constraint. Do not summarize the whole conversation.",
31
+ }),
32
+ }),
33
+ async execute(_toolCallId, params) {
27
34
  if (runtime.compactInFlight) {
28
35
  return {
29
36
  content: [{ type: "text" as const, text: "Context compaction is already in progress. Stop this turn and wait for automatic resume." }],
@@ -40,6 +47,7 @@ export function createCompactContextTool(runtime: Runtime) {
40
47
  }
41
48
 
42
49
  runtime.compactRequested = true;
50
+ runtime.compactContinuationPrompt = params.short_continuation_prompt.trim();
43
51
  return {
44
52
  content: [{ type: "text" as const, text: "Context compaction scheduled. Stop this turn; the task will resume automatically after compaction." }],
45
53
  details: { status: "scheduled" } as CompactContextDetails,