@matthewfl/pi-contemplator 0.0.2 → 0.0.4

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 CHANGED
@@ -96,7 +96,7 @@ pi -e ./src/index.ts
96
96
 
97
97
  ## Configuration and commands
98
98
 
99
- The plugin works with its defaults, including the contemplator and reviewer. Model selection, trigger thresholds, passive mode, compaction behavior, and other settings are documented in [docs/configuration.md](docs/configuration.md).
99
+ The plugin works with its defaults, including the contemplator and reviewer. Contemplator probes and review notices appear as purple cards in the chat by default; use `/om:settings messages off` to hide newly sent cards without stopping their delivery to the agent. Model selection, trigger thresholds, passive mode, compaction behavior, and other settings are documented in [docs/configuration.md](docs/configuration.md).
100
100
 
101
101
  Useful commands include:
102
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthewfl/pi-contemplator",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
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",
@@ -4,6 +4,7 @@ import type { Static } from "typebox";
4
4
  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
+ import { Box, Text } from "@earendil-works/pi-tui";
7
8
  import { assistantOutputTokens, assistantToolCallCount, fullProjection, isReviewRequestEntry, isReviewResultEntry, 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
9
  import { hashId } from "../../ids.js";
9
10
  import { createSearchMemoriesAgentTool } from "../../tools/search-memories.js";
@@ -80,9 +81,19 @@ function reviewSummaryLine(review: ReviewResult): string {
80
81
  function reviewRequestKey(request: RequestReviewArgs): string {
81
82
  return `${request.scope}:${hashId(`${request.evidence}\n${request.concern}`)}`;
82
83
  }
84
+
85
+ function customMessageText(content: unknown): string {
86
+ if (typeof content === "string") return content;
87
+ if (!Array.isArray(content)) return "";
88
+ return content
89
+ .map((block) => block && typeof block === "object" && "text" in block && typeof block.text === "string" ? block.text : "")
90
+ .filter(Boolean)
91
+ .join("\n");
92
+ }
83
93
  const CONTEMPLATOR_MESSAGE = "om.contemplator.message";
84
94
  const CONTEMPLATOR_STATE = "om.contemplator.state";
85
95
  const CONTEMPLATOR_SUGGESTION = "om.contemplator.suggestion";
96
+ const REVIEW_PROPOSAL_MESSAGE = "om.review.proposal";
86
97
  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." }) });
87
98
  const ReviewScopeSchema = Type.Union([Type.Literal("workflow"), Type.Literal("software")]);
88
99
  export const RequestReviewSchema = Type.Object({
@@ -143,7 +154,8 @@ export class Contemplator {
143
154
  private resumedReviewIds = new Set<string>();
144
155
  private reviewerSessions = new Map<string, ReviewerSession>();
145
156
  private deliveredProbeIds = new Set<string>();
146
- private requeuedProbeIds = new Set<string>();
157
+ /** Probe ids passed to pi.sendMessage by this live extension runtime. */
158
+ private queuedProbeIds = new Set<string>();
147
159
  private sessionGeneration = 0;
148
160
  private latestCtx: MemoryUpdateCtx | undefined;
149
161
  private turnsSinceRun = 0;
@@ -152,13 +164,31 @@ export class Contemplator {
152
164
  constructor(private readonly pi: ExtensionAPI, private readonly runtime: Runtime) {}
153
165
 
154
166
  register(): void {
167
+ this.pi.registerMessageRenderer(CONTEMPLATOR_SUGGESTION, (message, _options, theme) => {
168
+ const content = customMessageText(message.content).replace(/^Background contemplator probe \(advisory\):\n?/, "");
169
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
170
+ box.addChild(new Text(theme.fg("thinkingHigh", `${theme.bold("◆ CONTEMPLATOR PROBE")}\n${content}`), 0, 0));
171
+ return box;
172
+ });
173
+ this.pi.registerMessageRenderer(REVIEW_PROPOSAL_MESSAGE, (message, _options, theme) => {
174
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
175
+ box.addChild(new Text(theme.fg("thinkingHigh", `${theme.bold("◆ CONTEMPLATOR REVIEW")}\n${customMessageText(message.content)}`), 0, 0));
176
+ return box;
177
+ });
155
178
  this.runtime.setMemoryUpdateListener((ctx) => this.withDebugContext(ctx, () => this.observeTurn(ctx)));
156
- const restoreSessionBranch = (_event: any, ctx: ExtensionContext) => {
179
+ this.pi.on("session_start", (event: any, ctx: ExtensionContext) => {
157
180
  this.sessionGeneration++;
158
- this.restore(ctx, true);
159
- };
160
- this.pi.on("session_start", restoreSessionBranch);
161
- this.pi.on("session_tree", restoreSessionBranch);
181
+ // AgentSession preserves its steering queue across extension reloads. An
182
+ // undelivered tracking entry therefore still has a live queued message;
183
+ // restoring it here would enqueue the same probe a second time.
184
+ const reload = event?.reason === "reload";
185
+ this.restore(ctx, true, reload, reload);
186
+ });
187
+ this.pi.on("session_tree", (_event: any, ctx: ExtensionContext) => {
188
+ this.sessionGeneration++;
189
+ // Pending steering messages remain queued while navigating the tree.
190
+ this.restore(ctx, true, true);
191
+ });
162
192
  this.pi.on("session_shutdown", () => {
163
193
  this.sessionGeneration++;
164
194
  this.history = [];
@@ -172,7 +202,7 @@ export class Contemplator {
172
202
  this.resumedReviewIds.clear();
173
203
  this.reviewerSessions.clear();
174
204
  this.deliveredProbeIds.clear();
175
- this.requeuedProbeIds.clear();
205
+ this.queuedProbeIds.clear();
176
206
  this.latestCtx = undefined;
177
207
  this.turnsSinceRun = 0;
178
208
  this.restoredTipId = undefined;
@@ -220,7 +250,7 @@ export class Contemplator {
220
250
  }, fn);
221
251
  }
222
252
 
223
- private restore(ctx: MemoryUpdateCtx, resetTracking = false): void {
253
+ private restore(ctx: MemoryUpdateCtx, resetTracking = false, retainQueuedIds = false, skipUndeliveredRestore = false): void {
224
254
  this.latestCtx = ctx;
225
255
  const entries = ctx.sessionManager.getBranch() as Entry[];
226
256
  const tipId = entries.at(-1)?.id;
@@ -229,7 +259,7 @@ export class Contemplator {
229
259
  this.history = [];
230
260
  if (resetTracking) {
231
261
  this.deliveredProbeIds.clear();
232
- this.requeuedProbeIds.clear();
262
+ if (!retainQueuedIds) this.queuedProbeIds.clear();
233
263
  this.inFlightReviewIds.clear();
234
264
  this.resolvingReviewIds.clear();
235
265
  this.resumedReviewIds.clear();
@@ -312,8 +342,7 @@ export class Contemplator {
312
342
  }
313
343
  this.restoredTipId = tipId;
314
344
  for (const [probeId, question] of undeliveredSuggestions) {
315
- if (queuedProbeIds.has(probeId) || this.requeuedProbeIds.has(probeId)) continue;
316
- this.requeuedProbeIds.add(probeId);
345
+ if (skipUndeliveredRestore || queuedProbeIds.has(probeId) || this.queuedProbeIds.has(probeId)) continue;
317
346
  this.queueProbe(ctx, question, "restore", probeId);
318
347
  }
319
348
  if (resetTracking) void this.resumePendingReviews(ctx);
@@ -568,9 +597,13 @@ export class Contemplator {
568
597
  this.pi.sendMessage({
569
598
  customType: CONTEMPLATOR_SUGGESTION,
570
599
  content: `Background contemplator probe (advisory):\n${question}`,
571
- display: false,
600
+ display: this.runtime.config.showContemplatorMessages,
572
601
  details: { version: 1, question, source, probeId },
573
602
  }, { deliverAs: "steer", triggerTurn: false });
603
+ // sendMessage queues synchronously. Mark every source (not only restore)
604
+ // before a later turn_end can rebuild state from the still-undelivered
605
+ // tracking entry and enqueue this probe again.
606
+ this.queuedProbeIds.add(probeId);
574
607
  this.pi.appendEntry(CONTEMPLATOR_SUGGESTION, { version: 1, suggestion: question, delivered: false, source, probeId });
575
608
  this.markTipPersisted(ctx);
576
609
  debugLog("contemplator.suggestion_queued", {
@@ -646,7 +679,7 @@ export class Contemplator {
646
679
  debugLog(result.outcome === "proposal" ? "reviewer.proposal_created" : "reviewer.no_proposal", { reviewRequestId: request.id, reviewMemoryId: result.id, scope: result.scope });
647
680
  if (result.outcome === "proposal") {
648
681
  const notice = `BACKGROUND ${result.scope.toUpperCase()} REVIEW PROPOSAL [${result.id}]\n\n${result.summary}\n\nRecall memory [${result.id}] to read the full conceptual proposal when it is relevant.\n\nThis is advisory. Evaluate it against the actual environment and current work.`;
649
- this.pi.sendMessage({ customType: "om.review.proposal", content: notice, display: false, details: { version: 1, reviewRequestId: request.id, reviewMemoryId: result.id, scope: result.scope } }, { deliverAs: "steer", triggerTurn: false });
682
+ this.pi.sendMessage({ customType: REVIEW_PROPOSAL_MESSAGE, content: notice, display: this.runtime.config.showContemplatorMessages, details: { version: 1, reviewRequestId: request.id, reviewMemoryId: result.id, scope: result.scope } }, { deliverAs: "steer", triggerTurn: false });
650
683
  this.pi.appendEntry(OM_REVIEWER_NOTICE, { version: 1, reviewRequestId: request.id, reviewMemoryId: result.id, scope: result.scope, content: notice });
651
684
  this.markTipPersisted(ctx);
652
685
  debugLog("reviewer.primary_notice_queued", { reviewRequestId: request.id, reviewMemoryId: result.id });
@@ -71,6 +71,27 @@ function assistantOutputTokens(messages: AgentMessage[]): number {
71
71
  return total;
72
72
  }
73
73
 
74
+ function completeReview(request: StructuralReviewRequest, terminal: ReviewTerminalResult): ReviewResult {
75
+ return {
76
+ ...terminal,
77
+ id: hashId(`${request.id}:${JSON.stringify(terminal)}:${Date.now()}`),
78
+ version: 1,
79
+ reviewRequestId: request.id,
80
+ createdAt: Date.now(),
81
+ requestedBy: "contemplator",
82
+ } as ReviewResult;
83
+ }
84
+
85
+ function budgetExhaustedResult(request: StructuralReviewRequest): ReviewResult {
86
+ return completeReview(request, {
87
+ outcome: "no_proposal",
88
+ scope: request.scope,
89
+ reason: "The reviewer exhausted its lifetime output-token budget before recording a terminal proposal decision.",
90
+ evidenceReviewed: "The persisted reviewer transcript was retained, but no terminal evidence assessment was recorded before the budget was exhausted.",
91
+ reconsiderIf: "A new review can be requested with a narrower scope or a smaller evidence set.",
92
+ });
93
+ }
94
+
74
95
  export async function runStructuralReview(args: RunStructuralReviewArgs): Promise<ReviewResult | undefined> {
75
96
  let terminal: ReviewTerminalResult | undefined;
76
97
  const acceptTerminal = (candidate: ReviewTerminalResult): void => {
@@ -98,7 +119,7 @@ export async function runStructuralReview(args: RunStructuralReviewArgs): Promis
98
119
  // Usage on persisted assistant messages makes this a lifetime request budget,
99
120
  // rather than a fresh allowance on each session/tree resumption.
100
121
  let totalOutputTokens = assistantOutputTokens(history);
101
- if (totalOutputTokens >= REVIEWER_TOTAL_TOKEN_LIMIT) return undefined;
122
+ if (totalOutputTokens >= REVIEWER_TOTAL_TOKEN_LIMIT) return budgetExhaustedResult(args.request);
102
123
 
103
124
  // Persist both the user continuation and the returned messages immediately.
104
125
  // This makes the transcript sufficient to resume a review after shutdown.
@@ -175,13 +196,10 @@ export async function runStructuralReview(args: RunStructuralReviewArgs): Promis
175
196
  progress = await runOnce(keepGoing);
176
197
  invocations++;
177
198
  }
178
- if (!terminal) return undefined;
179
- return {
180
- ...terminal,
181
- id: hashId(`${args.request.id}:${JSON.stringify(terminal)}:${Date.now()}`),
182
- version: 1,
183
- reviewRequestId: args.request.id,
184
- createdAt: Date.now(),
185
- requestedBy: "contemplator",
186
- } as ReviewResult;
199
+ if (!terminal) {
200
+ return totalOutputTokens >= REVIEWER_TOTAL_TOKEN_LIMIT
201
+ ? budgetExhaustedResult(args.request)
202
+ : undefined;
203
+ }
204
+ return completeReview(args.request, terminal);
187
205
  }
@@ -10,7 +10,7 @@ type ModelRegistryLike = {
10
10
  getAll(): Array<{ provider: string; id: string }>;
11
11
  };
12
12
  type NumberSetting = "observeAfterTokens" | "reflectAfterTokens" | "compactAfterTokens" | "observerChunkMaxTokens" | "observationsPoolMaxTokens" | "observationsPoolTargetTokens" | "agentMaxTurns" | "contemplatorMinNewObservations" | "contemplatorMinNewReflections" | "contemplatorMinTurns";
13
- type BooleanSetting = "contemplatorEnabled" | "reviewerEnabled" | "compactionObserverEnabled" | "showWorkerNotifications" | "passive" | "debugLog";
13
+ type BooleanSetting = "contemplatorEnabled" | "showContemplatorMessages" | "reviewerEnabled" | "compactionObserverEnabled" | "showWorkerNotifications" | "passive" | "debugLog";
14
14
 
15
15
  function modelLabel(model: ConfiguredModel | undefined): string {
16
16
  return model ? `${model.provider}/${model.id}` : "current session model";
@@ -175,8 +175,13 @@ export function registerSettingsCommand(pi: ExtensionAPI, runtime: Runtime): voi
175
175
  ctx.ui.notify(`Structural reviewer: ${argument.endsWith("on") ? "enabled" : "disabled"} for this session.`, "info");
176
176
  return;
177
177
  }
178
+ if (argument === "messages on" || argument === "messages off") {
179
+ appendSettings(pi, runtime, { showContemplatorMessages: argument.endsWith("on") });
180
+ ctx.ui.notify(`Contemplator messages: ${argument.endsWith("on") ? "visible" : "hidden"} for this session.`, "info");
181
+ return;
182
+ }
178
183
  if (argument) {
179
- ctx.ui.notify("Usage: /om:settings [on|off|reviewer on|reviewer off|compaction on|compaction off]", "info");
184
+ ctx.ui.notify("Usage: /om:settings [on|off|messages on|messages off|reviewer on|reviewer off|compaction on|compaction off]", "info");
180
185
  return;
181
186
  }
182
187
 
@@ -185,6 +190,7 @@ export function registerSettingsCommand(pi: ExtensionAPI, runtime: Runtime): voi
185
190
  const choice = await ctx.ui.select("Observational memory settings (session overrides)", [
186
191
  `Contemplation: ${scalarLabel(runtime, "contemplatorEnabled")}`,
187
192
  `Contemplation model: ${hasOverride(settings, "contemplatorModel") ? modelLabel(runtime.config.contemplatorModel) : `default (${modelLabel(runtime.getDefaultConfig().contemplatorModel)})`}`,
193
+ `Contemplator messages visible: ${scalarLabel(runtime, "showContemplatorMessages")}`,
188
194
  `Structural reviewer: ${scalarLabel(runtime, "reviewerEnabled")}`,
189
195
  `Structural reviewer model: ${hasOverride(settings, "reviewerModel") ? modelLabel(runtime.config.reviewerModel) : `default (${modelLabel(runtime.getDefaultConfig().reviewerModel)})`}`,
190
196
  `Compaction observer: ${scalarLabel(runtime, "compactionObserverEnabled")}`,
@@ -208,6 +214,7 @@ export function registerSettingsCommand(pi: ExtensionAPI, runtime: Runtime): voi
208
214
  ]);
209
215
  if (!choice || choice === "Done") return;
210
216
  if (choice.startsWith("Contemplation:")) appendSettings(pi, runtime, { contemplatorEnabled: !runtime.config.contemplatorEnabled });
217
+ else if (choice.startsWith("Contemplator messages visible:")) appendSettings(pi, runtime, { showContemplatorMessages: !runtime.config.showContemplatorMessages });
211
218
  else if (choice.startsWith("Structural reviewer:")) appendSettings(pi, runtime, { reviewerEnabled: !runtime.config.reviewerEnabled });
212
219
  else if (choice.startsWith("Compaction observer:")) appendSettings(pi, runtime, { compactionObserverEnabled: !runtime.config.compactionObserverEnabled });
213
220
  else if (choice.startsWith("Worker notifications:")) appendSettings(pi, runtime, { showWorkerNotifications: !runtime.config.showWorkerNotifications });
@@ -102,6 +102,7 @@ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void
102
102
  `Compaction observer: ${runtime.config.compactionObserverEnabled === false ? "disabled" : "enabled"}`,
103
103
  `Contemplator: ${runtime.config.contemplatorEnabled ? "enabled" : "disabled"}`,
104
104
  `Contemplator model: ${runtime.config.contemplatorModel ? `${runtime.config.contemplatorModel.provider}/${runtime.config.contemplatorModel.id}` : "current session model"}`,
105
+ `Contemplator messages: ${runtime.config.showContemplatorMessages ? "visible" : "hidden"}`,
105
106
  `Structural reviewer: ${runtime.config.reviewerEnabled === false ? "disabled" : "enabled"}`,
106
107
  `Reviewer model: ${runtime.config.reviewerModel ? `${runtime.config.reviewerModel.provider}/${runtime.config.reviewerModel.id}` : "current session model"}`,
107
108
  ];
package/src/config.ts CHANGED
@@ -52,6 +52,8 @@ export interface Config {
52
52
  compactionObserverEnabled: boolean;
53
53
  contemplatorEnabled: boolean;
54
54
  contemplatorModel?: ConfiguredModel;
55
+ /** Show contemplator probes and review notices in the chat transcript. */
56
+ showContemplatorMessages: boolean;
55
57
  /** Allow the contemplator to commission scoped structural reviewers. */
56
58
  reviewerEnabled: boolean;
57
59
  /** Optional model override used only by short-lived structural reviewers. */
@@ -75,6 +77,7 @@ export const DEFAULTS: Config = {
75
77
  passive: false,
76
78
  compactionObserverEnabled: true,
77
79
  contemplatorEnabled: true,
80
+ showContemplatorMessages: true,
78
81
  reviewerEnabled: true,
79
82
  contemplatorMinNewObservations: 8,
80
83
  contemplatorMinNewReflections: 1,
@@ -223,6 +226,7 @@ function normalizeSettingsConfig(value: Record<string, unknown>): Partial<Config
223
226
  if (typeof value.passive === "boolean") normalized.passive = value.passive;
224
227
  if (typeof value.compactionObserverEnabled === "boolean") normalized.compactionObserverEnabled = value.compactionObserverEnabled;
225
228
  if (typeof value.contemplatorEnabled === "boolean") normalized.contemplatorEnabled = value.contemplatorEnabled;
229
+ if (typeof value.showContemplatorMessages === "boolean") normalized.showContemplatorMessages = value.showContemplatorMessages;
226
230
  if (typeof value.reviewerEnabled === "boolean") normalized.reviewerEnabled = value.reviewerEnabled;
227
231
  if (typeof value.debugLog === "boolean") normalized.debugLog = value.debugLog;
228
232
  const model = normalizeModel(value.model);
package/src/runtime.ts CHANGED
@@ -35,7 +35,7 @@ export type SessionSettings = Partial<Pick<Config,
35
35
  | "observeAfterTokens" | "reflectAfterTokens" | "observerChunkMaxTokens" | "compactAfterTokens"
36
36
  | "compactAfterTokensMode" | "compactAfterTokensRatio"
37
37
  | "observationsPoolMaxTokens" | "observationsPoolTargetTokens" | "agentMaxTurns"
38
- | "showWorkerNotifications" | "passive" | "compactionObserverEnabled" | "contemplatorEnabled" | "reviewerEnabled"
38
+ | "showWorkerNotifications" | "passive" | "compactionObserverEnabled" | "contemplatorEnabled" | "showContemplatorMessages" | "reviewerEnabled"
39
39
  | "contemplatorMinNewObservations" | "contemplatorMinNewReflections" | "contemplatorMinTurns" | "debugLog"
40
40
  >> & {
41
41
  /** null explicitly means use the configured/session model. */
@@ -108,7 +108,7 @@ export function computeSessionSettings(entries: readonly unknown[]): SessionSett
108
108
  if (!source || typeof source !== "object") return;
109
109
  const data = source as Record<string, unknown>;
110
110
  const booleanKeys = [
111
- "showWorkerNotifications", "passive", "compactionObserverEnabled", "contemplatorEnabled", "reviewerEnabled", "debugLog",
111
+ "showWorkerNotifications", "passive", "compactionObserverEnabled", "contemplatorEnabled", "showContemplatorMessages", "reviewerEnabled", "debugLog",
112
112
  ] as const;
113
113
  const numberKeys = [
114
114
  "observeAfterTokens", "reflectAfterTokens", "observerChunkMaxTokens", "compactAfterTokens",