@matthewfl/pi-contemplator 0.4.0 → 0.4.2

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