@matthewfl/pi-contemplator 0.4.0 → 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthewfl/pi-contemplator",
3
- "version": "0.4.0",
3
+ "version": "0.4.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,5 +1,6 @@
1
1
  import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage, type AgentTool, type StreamFn } from "@earendil-works/pi-agent-core";
2
2
  import { Type, type Message, type Model } from "@earendil-works/pi-ai";
3
+ import { isRetryableAssistantError } from "@earendil-works/pi-ai/utils/retry";
3
4
  import type { Static } from "typebox";
4
5
  import { estimateTokens as estimateAgentMessageTokens, generateSummaryWithUsage } from "@earendil-works/pi-coding-agent";
5
6
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -23,6 +24,8 @@ const CONTEMPLATOR_HISTORY_MAX_TRIGGER_TOKENS = 40_000;
23
24
  const CONTEMPLATOR_HISTORY_CONTEXT_FRACTION = 0.25;
24
25
  const CONTEMPLATOR_HISTORY_KEEP_RECENT_TOKENS = 12_000;
25
26
  const CONTEMPLATOR_HISTORY_SUMMARY_RESERVE_TOKENS = 16_000;
27
+ const CONTEMPLATOR_UPDATE_MAX_TOKENS = 20_000;
28
+ const CONTEMPLATOR_UPDATE_CONTEXT_FRACTION = 0.2;
26
29
  const CONTEMPLATOR_HISTORY_SUMMARY_INSTRUCTIONS = "This is an older prefix of a private contemplator transcript. Be concise. Preserve durable user intent, decisions, evidence, unresolved reasoning gaps, and review/probe outcomes. Newer transcript messages are retained verbatim after this checkpoint.";
27
30
 
28
31
  function contemplatorMessageTokens(message: AgentMessage): number {
@@ -145,6 +148,72 @@ function reviewSummaryLine(review: ReviewResult): string {
145
148
  : `[${review.id}] ${review.scope} review concluded with no proposal — ${review.reason}`;
146
149
  }
147
150
 
151
+ function memoryLineId(line: string): string | undefined {
152
+ return line.match(/^\[([0-9a-f]{12})\]/)?.[1];
153
+ }
154
+
155
+ function contemplatorUpdateBudgetTokens(model: Model<any>): number {
156
+ const contextWindow = Number(model.contextWindow);
157
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) return CONTEMPLATOR_UPDATE_MAX_TOKENS;
158
+ return Math.max(1_024, Math.min(CONTEMPLATOR_UPDATE_MAX_TOKENS, Math.floor(contextWindow * CONTEMPLATOR_UPDATE_CONTEXT_FRACTION)));
159
+ }
160
+
161
+ /**
162
+ * Keep the newest pending records within a bounded request budget. The complete
163
+ * ids are persisted as coverage separately, so intentionally omitted old
164
+ * records do not reappear after reload even though their bodies are not copied
165
+ * into the private contemplator transcript.
166
+ */
167
+ function boundedContemplatorUpdate(observations: string[], reviews: string[], budgetTokens: number): {
168
+ observations: string[];
169
+ reviews: string[];
170
+ omitted: number;
171
+ } {
172
+ type Candidate = { kind: "observation" | "review"; index: number; line: string };
173
+ // Reviews are rare terminal outcomes and get first claim on the budget. Within
174
+ // each section, newest records are selected first and rendered chronologically.
175
+ const candidates: Candidate[] = [
176
+ ...reviews.map((line, index) => ({ kind: "review" as const, index, line })).reverse(),
177
+ ...observations.map((line, index) => ({ kind: "observation" as const, index, line })).reverse(),
178
+ ];
179
+ const selected = new Map<Candidate, string>();
180
+ let remaining = budgetTokens;
181
+ for (const candidate of candidates) {
182
+ const tokens = Math.max(1, Math.ceil(candidate.line.length / 4));
183
+ if (tokens <= remaining) {
184
+ selected.set(candidate, candidate.line);
185
+ remaining -= tokens;
186
+ continue;
187
+ }
188
+ // Always expose at least the newest record. An individually enormous memory
189
+ // is excerpted rather than defeating the safety cap; recall retains the full
190
+ // source under the id shown at the beginning of the excerpt.
191
+ if (selected.size === 0 && remaining > 0) {
192
+ const maxChars = Math.max(256, remaining * 4);
193
+ selected.set(candidate, `${candidate.line.slice(0, Math.max(1, maxChars - 96))}\n… [memory excerpt capped; use recall for the full record]`);
194
+ remaining = 0;
195
+ }
196
+ }
197
+ const selectedObservations = candidates
198
+ .filter((candidate) => candidate.kind === "observation" && selected.has(candidate))
199
+ .sort((left, right) => left.index - right.index)
200
+ .map((candidate) => selected.get(candidate)!);
201
+ const selectedReviews = candidates
202
+ .filter((candidate) => candidate.kind === "review" && selected.has(candidate))
203
+ .sort((left, right) => left.index - right.index)
204
+ .map((candidate) => selected.get(candidate)!);
205
+ return {
206
+ observations: selectedObservations,
207
+ reviews: selectedReviews,
208
+ omitted: observations.length + reviews.length - selected.size,
209
+ };
210
+ }
211
+
212
+ function retryableContemplatorFailure(message: string): boolean {
213
+ return isRetryableAssistantError({ stopReason: "error", errorMessage: message } as any)
214
+ || /produced no progress|runtime ceiling/i.test(message);
215
+ }
216
+
148
217
  function reviewRequestKey(request: RequestReviewArgs): string {
149
218
  return `${request.scope}:${hashId(`${request.evidence}\n${request.concern}`)}`;
150
219
  }
@@ -617,6 +686,11 @@ export class Contemplator {
617
686
  }
618
687
  if (entry.customType === CONTEMPLATOR_MESSAGE && entry.data && typeof entry.data === "object") {
619
688
  const data = entry.data as { message?: unknown; compacted?: unknown; retainedMessageEntryIds?: unknown; coveredObservationIds?: unknown; coveredReviewIds?: unknown };
689
+ // Coverage may be broader than the rendered prompt when a long-dead
690
+ // contemplator returns to a large backlog. Persisting compact id lists
691
+ // avoids copying every old memory body while keeping restore stable.
692
+ if (Array.isArray(data.coveredObservationIds)) for (const id of data.coveredObservationIds) if (typeof id === "string") checkpointCoveredObservationIds.add(id);
693
+ if (Array.isArray(data.coveredReviewIds)) for (const id of data.coveredReviewIds) if (typeof id === "string") checkpointCoveredReviewIds.add(id);
620
694
  const message = data.message;
621
695
  if (message && typeof message === "object") {
622
696
  const typedMessage = message as AgentMessage;
@@ -627,8 +701,6 @@ export class Contemplator {
627
701
  : [];
628
702
  this.history = [typedMessage, ...retainedIds.map((id) => historyMessagesByEntryId.get(id)!)];
629
703
  this.historyEntryIds = [entry.id, ...retainedIds];
630
- if (Array.isArray(data.coveredObservationIds)) for (const id of data.coveredObservationIds) if (typeof id === "string") checkpointCoveredObservationIds.add(id);
631
- if (Array.isArray(data.coveredReviewIds)) for (const id of data.coveredReviewIds) if (typeof id === "string") checkpointCoveredReviewIds.add(id);
632
704
  } else {
633
705
  this.history.push(typedMessage);
634
706
  this.historyEntryIds.push(entry.id);
@@ -880,6 +952,7 @@ export class Contemplator {
880
952
  this.turnsSinceRun = 0;
881
953
  const startedAt = Date.now();
882
954
  let failed = false;
955
+ let transientFailure = false;
883
956
  let failureMessage: string | undefined;
884
957
  let workerNotified = false;
885
958
  let promptPersisted = false;
@@ -940,10 +1013,25 @@ export class Contemplator {
940
1013
  workerNotified = true;
941
1014
  }
942
1015
  const reviewerEnabled = this.runtime.config.reviewerEnabled;
1016
+ const boundedUpdate = boundedContemplatorUpdate(
1017
+ update.observations,
1018
+ update.reviews,
1019
+ contemplatorUpdateBudgetTokens(resolved.model as Model<any>),
1020
+ );
943
1021
  const updateSections: string[] = [];
944
- if (update.observations.length > 0) updateSections.push(`OBSERVATIONS:\n${update.observations.join("\n")}`);
945
- if (update.reviews.length > 0) updateSections.push(`REVIEWS:\n${update.reviews.join("\n")}`);
1022
+ if (boundedUpdate.observations.length > 0) updateSections.push(`OBSERVATIONS:\n${boundedUpdate.observations.join("\n")}`);
1023
+ if (boundedUpdate.reviews.length > 0) updateSections.push(`REVIEWS:\n${boundedUpdate.reviews.join("\n")}`);
1024
+ if (boundedUpdate.omitted > 0) updateSections.push(`BACKLOG SAFETY CAP: ${boundedUpdate.omitted} older pending memory record${boundedUpdate.omitted === 1 ? " was" : "s were"} omitted from this update. They remain searchable and recallable; focus on the newest records shown here.`);
946
1025
  const updateBody = updateSections.length > 0 ? updateSections.join("\n\n") : "(no new memories)";
1026
+ const coveredObservationIds = update.observations.map(memoryLineId).filter((id): id is string => id !== undefined);
1027
+ const coveredReviewIds = update.reviews.map(memoryLineId).filter((id): id is string => id !== undefined);
1028
+ debugLog("contemplator.update_bounded", {
1029
+ observationCount: update.observations.length,
1030
+ reviewCount: update.reviews.length,
1031
+ renderedObservationCount: boundedUpdate.observations.length,
1032
+ renderedReviewCount: boundedUpdate.reviews.length,
1033
+ omittedCount: boundedUpdate.omitted,
1034
+ });
947
1035
  const sparseMemoryDescription = update.observations.length + update.reviews.length === 0
948
1036
  ? "No new memories were created during this interval."
949
1037
  : `Only ${update.observations.length + update.reviews.length} new memory record${update.observations.length + update.reviews.length === 1 ? " was" : "s were"} created during this interval.`;
@@ -1061,6 +1149,7 @@ export class Contemplator {
1061
1149
  const errorMessage = "errorMessage" in assistant && typeof assistant.errorMessage === "string"
1062
1150
  ? assistant.errorMessage
1063
1151
  : `Contemplator model ${assistant.stopReason}`;
1152
+ transientFailure = assistant.stopReason === "error" && retryableContemplatorFailure(errorMessage);
1064
1153
  throw new Error(errorMessage);
1065
1154
  }
1066
1155
  if (!intervention && invocation < CONTEMPLATOR_MAX_INVOCATIONS) {
@@ -1075,7 +1164,9 @@ export class Contemplator {
1075
1164
  for (const message of runMessages) {
1076
1165
  if (message.role !== "user" && message.role !== "assistant") continue;
1077
1166
  this.history.push(message);
1078
- this.historyEntryIds.push(this.appendContemplatorHistoryEntry(ctx, { version: 1, message }));
1167
+ this.historyEntryIds.push(this.appendContemplatorHistoryEntry(ctx, message === prompt
1168
+ ? { version: 2, message, coveredObservationIds, coveredReviewIds }
1169
+ : { version: 1, message }));
1079
1170
  promptPersisted = true;
1080
1171
  this.markTipPersisted(ctx);
1081
1172
  }
@@ -1115,10 +1206,12 @@ export class Contemplator {
1115
1206
  } catch (error) {
1116
1207
  failed = true;
1117
1208
  failureMessage = error instanceof Error ? error.message : String(error);
1118
- debugLog("contemplator.error", { errorMessage: failureMessage });
1209
+ transientFailure ||= retryableContemplatorFailure(failureMessage);
1210
+ debugLog("contemplator.error", { errorMessage: failureMessage, transientFailure });
1119
1211
  if (sessionGeneration === this.sessionGeneration && !promptPersisted) {
1120
- this.consecutiveFlushFailures++;
1121
- if (this.consecutiveFlushFailures < 2) {
1212
+ if (transientFailure) this.consecutiveFlushFailures = 0;
1213
+ else this.consecutiveFlushFailures++;
1214
+ if (transientFailure || this.consecutiveFlushFailures < 2) {
1122
1215
  const pending = this.pending as PendingUpdate | undefined;
1123
1216
  this.pending = {
1124
1217
  observations: mergeMemoryLines(pending?.observations ?? [], update.observations),
@@ -1162,9 +1255,10 @@ export class Contemplator {
1162
1255
  0,
1163
1256
  agentActiveTimeMs(ctx.sessionManager.getBranch() as Entry[]) - this.lastContemplatorActiveTimeMs,
1164
1257
  );
1258
+ const finishedAt = Date.now();
1165
1259
  this.publishState(waitingFor, {
1166
1260
  activeTimeSinceRunMs,
1167
- lastCompletedAt: Date.now(),
1261
+ ...(failed ? { lastFailedAt: finishedAt } : { lastCompletedAt: finishedAt, lastFailedAt: undefined }),
1168
1262
  lastError: failureMessage,
1169
1263
  });
1170
1264
  debugLog("contemplator.complete", {
@@ -1510,13 +1604,24 @@ export class Contemplator {
1510
1604
  // per checkpoint, and restore unions checkpoints, avoiding cumulative O(n²)
1511
1605
  // id duplication across a very long session.
1512
1606
  const prefixCoveredIds = new Set<string>();
1513
- for (const message of history.slice(0, prefixEnd)) {
1514
- if (message.role !== "user") continue;
1515
- const text = customMessageText(message.content);
1516
- if (!text.includes("NEW MEMORY UPDATE")) continue;
1517
- for (const id of memoryReferenceIds(text)) prefixCoveredIds.add(id);
1607
+ const branchEntries = ctx.sessionManager.getBranch() as Entry[];
1608
+ const branchById = new Map(branchEntries.map((entry) => [entry.id, entry]));
1609
+ for (let index = 0; index < prefixEnd; index++) {
1610
+ const message = history[index];
1611
+ if (message.role === "user") {
1612
+ const text = customMessageText(message.content);
1613
+ if (text.includes("NEW MEMORY UPDATE")) for (const id of memoryReferenceIds(text)) prefixCoveredIds.add(id);
1614
+ }
1615
+ // A safety-capped prompt intentionally covers records whose bodies were
1616
+ // omitted. Carry those compact id references into the replacement
1617
+ // checkpoint before its original message entry becomes unreachable.
1618
+ const entryId = historyEntryIds[index];
1619
+ const data = entryId === undefined ? undefined : branchById.get(entryId)?.data as { coveredObservationIds?: unknown; coveredReviewIds?: unknown } | undefined;
1620
+ for (const ids of [data?.coveredObservationIds, data?.coveredReviewIds]) {
1621
+ if (Array.isArray(ids)) for (const id of ids) if (typeof id === "string") prefixCoveredIds.add(id);
1622
+ }
1518
1623
  }
1519
- const currentProjection = fullProjection(ctx.sessionManager.getBranch() as Entry[]);
1624
+ const currentProjection = fullProjection(branchEntries);
1520
1625
  const checkpoint = {
1521
1626
  version: 2,
1522
1627
  compacted: true,
@@ -60,7 +60,7 @@ function renderMessage(message: StoredMessage, compacted: boolean): string {
60
60
 
61
61
  function liveStateLine(state: ContemplatorRunState): string {
62
62
  const pending = `${state.pendingObservations} observations / ${state.pendingReviews} reviews pending`;
63
- const timing = `Last start: ${state.lastStartedAt === undefined ? "not run this launch" : new Date(state.lastStartedAt).toISOString()} · Last end: ${state.lastCompletedAt === undefined ? "not completed this launch" : new Date(state.lastCompletedAt).toISOString()}`;
63
+ const timing = `Last start: ${state.lastStartedAt === undefined ? "not run this launch" : new Date(state.lastStartedAt).toISOString()} · Last success: ${state.lastCompletedAt === undefined ? "none this launch" : new Date(state.lastCompletedAt).toISOString()} · Last failure: ${state.lastFailedAt === undefined ? "none this launch" : new Date(state.lastFailedAt).toISOString()}`;
64
64
  const error = state.lastError ? `\nLast error: ${state.lastError}` : "";
65
65
  if (state.running) return `LIVE · running for ${Math.max(0, Math.floor((Date.now() - (state.lastStartedAt ?? Date.now())) / 60_000))}m · ${pending}\n${timing}${error}`;
66
66
  const reason = state.waitingFor === "observer"
@@ -147,7 +147,8 @@ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void
147
147
  lines.push(`Last summarizer start: ${runtime.lastSummarizerStartedAt === undefined ? "not run this launch" : formatRunAge(runtime.lastSummarizerStartedAt)}`);
148
148
  lines.push(`Last summarizer end: ${runtime.lastSummarizerCompletedAt === undefined ? "not completed this launch" : formatRunAge(runtime.lastSummarizerCompletedAt)}`);
149
149
  lines.push(`Last contemplator start: ${runtime.contemplatorState.lastStartedAt === undefined ? "not run this launch" : formatRunAge(runtime.contemplatorState.lastStartedAt)}`);
150
- lines.push(`Last contemplator end: ${runtime.contemplatorState.lastCompletedAt === undefined ? "not completed this launch" : formatRunAge(runtime.contemplatorState.lastCompletedAt)}`);
150
+ lines.push(`Last contemplator success: ${runtime.contemplatorState.lastCompletedAt === undefined ? "not completed this launch" : formatRunAge(runtime.contemplatorState.lastCompletedAt)}`);
151
+ lines.push(`Last contemplator failure: ${runtime.contemplatorState.lastFailedAt === undefined ? "none this launch" : formatRunAge(runtime.contemplatorState.lastFailedAt)}`);
151
152
 
152
153
  lines.push("", "── Interventions ──");
153
154
  // Probe stats come from the branch ledger (like /om:view contemplator):
package/src/runtime.ts CHANGED
@@ -96,7 +96,10 @@ export interface ContemplatorRunState {
96
96
  activeTimeSinceRunMs: number;
97
97
  waitingFor: "disabled" | "passive" | "observer" | "probe" | "memories" | "responses" | "ready" | "running" | "idle";
98
98
  lastStartedAt?: number;
99
+ /** Most recent successful final-action completion. */
99
100
  lastCompletedAt?: number;
101
+ /** Most recent failed attempt; distinct so a timeout is never shown as success. */
102
+ lastFailedAt?: number;
100
103
  lastError?: string;
101
104
  }
102
105