@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.
|
|
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 (
|
|
945
|
-
if (
|
|
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,
|
|
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
|
-
|
|
1209
|
+
transientFailure ||= retryableContemplatorFailure(failureMessage);
|
|
1210
|
+
debugLog("contemplator.error", { errorMessage: failureMessage, transientFailure });
|
|
1119
1211
|
if (sessionGeneration === this.sessionGeneration && !promptPersisted) {
|
|
1120
|
-
this.consecutiveFlushFailures
|
|
1121
|
-
|
|
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:
|
|
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
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
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(
|
|
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
|
|
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"
|
package/src/commands/status.ts
CHANGED
|
@@ -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
|
|
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
|
|