@matthewfl/pi-contemplator 0.1.13 → 0.1.15

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.1.13",
3
+ "version": "0.1.15",
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",
@@ -50,10 +50,11 @@
50
50
  "@earendil-works/pi-tui": "*"
51
51
  },
52
52
  "devDependencies": {
53
- "@earendil-works/pi-agent-core": "^0.84.4",
54
- "@earendil-works/pi-ai": "^0.84.4",
55
- "@earendil-works/pi-coding-agent": "^0.84.4",
56
- "@earendil-works/pi-tui": "^0.84.4",
53
+ "@earendil-works/pi-agent-core": "^0.85.0",
54
+ "@earendil-works/pi-ai": "^0.85.0",
55
+ "@earendil-works/pi-coding-agent": "^0.85.0",
56
+ "@earendil-works/pi-server": "^0.85.0",
57
+ "@earendil-works/pi-tui": "^0.85.0",
57
58
  "@types/node": "^22.0.0",
58
59
  "typebox": "^1.1.38",
59
60
  "typescript": "^5.6.0",
@@ -506,6 +506,8 @@ export class Contemplator {
506
506
  this.history = [];
507
507
  this.historyEntryIds = [];
508
508
  const historyMessagesByEntryId = new Map<string, AgentMessage>();
509
+ const checkpointCoveredObservationIds = new Set<string>();
510
+ const checkpointCoveredReviewIds = new Set<string>();
509
511
  let resetProjection: ReturnType<typeof fullProjection> | undefined;
510
512
  if (resetTracking) {
511
513
  this.deliveredProbeIds.clear();
@@ -539,7 +541,7 @@ export class Contemplator {
539
541
  }
540
542
  }
541
543
  if (entry.customType === CONTEMPLATOR_MESSAGE && entry.data && typeof entry.data === "object") {
542
- const data = entry.data as { message?: unknown; compacted?: unknown; retainedMessageEntryIds?: unknown };
544
+ const data = entry.data as { message?: unknown; compacted?: unknown; retainedMessageEntryIds?: unknown; coveredObservationIds?: unknown; coveredReviewIds?: unknown };
543
545
  const message = data.message;
544
546
  if (message && typeof message === "object") {
545
547
  const typedMessage = message as AgentMessage;
@@ -550,6 +552,8 @@ export class Contemplator {
550
552
  : [];
551
553
  this.history = [typedMessage, ...retainedIds.map((id) => historyMessagesByEntryId.get(id)!)];
552
554
  this.historyEntryIds = [entry.id, ...retainedIds];
555
+ if (Array.isArray(data.coveredObservationIds)) for (const id of data.coveredObservationIds) if (typeof id === "string") checkpointCoveredObservationIds.add(id);
556
+ if (Array.isArray(data.coveredReviewIds)) for (const id of data.coveredReviewIds) if (typeof id === "string") checkpointCoveredReviewIds.add(id);
553
557
  } else {
554
558
  this.history.push(typedMessage);
555
559
  this.historyEntryIds.push(entry.id);
@@ -607,10 +611,10 @@ export class Contemplator {
607
611
  }
608
612
  }
609
613
  if (resetTracking && resetProjection) {
610
- // Successful contemplator update prompts are the durable coverage record.
611
- // Only memories present in those prompts are considered seen after reload;
612
- // memories from a failed, unpersisted run remain pending and retryable.
613
- const coveredIds = new Set<string>();
614
+ // Successful update prompts and private-history compaction checkpoints are
615
+ // the durable coverage record. Checkpoints carry ids from prompts replaced
616
+ // by their summary; failed, unpersisted runs remain pending and retryable.
617
+ const coveredIds = new Set<string>([...checkpointCoveredObservationIds, ...checkpointCoveredReviewIds]);
614
618
  for (const message of this.history) {
615
619
  if (message.role !== "user") continue;
616
620
  const text = customMessageText(message.content);
@@ -1359,7 +1363,28 @@ export class Contemplator {
1359
1363
  debugLog("contemplator.compaction_postponed", { reason: "retained history lacked durable entry ids", retainedMessageCount: retainedMessages.length, retainedReferenceCount: retainedMessageEntryIds.length });
1360
1364
  return;
1361
1365
  }
1362
- const checkpoint = { version: 2, compacted: true, message: summaryMessage, retainedMessageEntryIds };
1366
+ // Record only coverage evidenced by the exact durable prompt prefix being
1367
+ // replaced. `seen*Ids` may also contain observations appended concurrently
1368
+ // while summary generation awaited; those remain pending and must not be
1369
+ // declared covered before their own prompt is persisted. Coverage is a delta
1370
+ // per checkpoint, and restore unions checkpoints, avoiding cumulative O(n²)
1371
+ // id duplication across a very long session.
1372
+ const prefixCoveredIds = new Set<string>();
1373
+ for (const message of history.slice(0, prefixEnd)) {
1374
+ if (message.role !== "user") continue;
1375
+ const text = customMessageText(message.content);
1376
+ if (!text.includes("NEW MEMORY UPDATE")) continue;
1377
+ for (const id of memoryReferenceIds(text)) prefixCoveredIds.add(id);
1378
+ }
1379
+ const currentProjection = fullProjection(ctx.sessionManager.getBranch() as Entry[]);
1380
+ const checkpoint = {
1381
+ version: 2,
1382
+ compacted: true,
1383
+ message: summaryMessage,
1384
+ retainedMessageEntryIds,
1385
+ coveredObservationIds: currentProjection.observations.filter((item) => prefixCoveredIds.has(item.id)).map((item) => item.id),
1386
+ coveredReviewIds: (currentProjection.reviews ?? []).filter((item) => prefixCoveredIds.has(item.id)).map((item) => item.id),
1387
+ };
1363
1388
  const checkpointEntryId = this.appendContemplatorHistoryEntry(ctx, checkpoint);
1364
1389
  this.history = [summaryMessage, ...retainedMessages];
1365
1390
  this.historyEntryIds = [checkpointEntryId, ...retainedMessageEntryIds];
@@ -106,6 +106,27 @@ function textResult(text: string, details: Record<string, unknown> = {}, termina
106
106
  return { content: [{ type: "text" as const, text }], details, ...(terminate ? { terminate: true } : {}) };
107
107
  }
108
108
 
109
+ /**
110
+ * Many provider chat templates discard historical reasoning blocks. Preserve
111
+ * unfinished plaintext work after an output-length stop by replaying it as
112
+ * ordinary assistant text; unlike provider-specific thinking metadata, text
113
+ * survives every supported conversation serializer. Redacted/encrypted blocks
114
+ * must remain structured so their opaque provider payload stays replayable.
115
+ * The durable/in-memory transcript remains unchanged—this transformation is
116
+ * only applied at the LLM boundary.
117
+ */
118
+ export function replayTruncatedThinkingAsText(messages: readonly AgentMessage[]): Message[] {
119
+ return messages.map((message) => {
120
+ if (message.role !== "assistant" || message.stopReason !== "length" || !message.content.some((part) => part.type === "thinking" && !part.redacted)) return message as Message;
121
+ return {
122
+ ...message,
123
+ content: message.content.map((part) => part.type === "thinking" && !part.redacted
124
+ ? { type: "text" as const, text: `[Incomplete analysis from the preceding truncated response]\n${part.thinking}` }
125
+ : part),
126
+ } as Message;
127
+ });
128
+ }
129
+
109
130
  function preview(content: string): string {
110
131
  const compact = content.replace(/\s+/g, " ").trim();
111
132
  return compact.length <= 100 ? compact : `${compact.slice(0, 100)}…`;
@@ -540,7 +561,7 @@ export async function runSummarizer(args: RunSummarizerArgs): Promise<Summarizer
540
561
  apiKey: args.apiKey,
541
562
  headers: args.headers,
542
563
  maxTokens: boundedMaxTokens(args.model, maxOutputTokens),
543
- convertToLlm: (messages) => messages as Message[],
564
+ convertToLlm: replayTruncatedThinkingAsText,
544
565
  toolExecution: "sequential",
545
566
  beforeToolCall: async ({ toolCall, context: toolContext }) => {
546
567
  if (toolCall.name !== "done") return undefined;
@@ -597,7 +618,7 @@ export async function runSummarizer(args: RunSummarizerArgs): Promise<Summarizer
597
618
  let stopReason = await runOnce("The preceding summarize call and receipt are an illustrative example only. Its placeholder ids are not real and it did not create a summary. Now pick any five coherent groups of actual memories worth combining and use summarize to create about five summary memories. Do not rank the whole pool; the first five worthwhile groups are good enough, and you will have more chances afterward. Record a summary as soon as it looks reasonable rather than drafting all five in prose or thinking. If you create one in prose or thinking, record it with summarize immediately. Memories not combined remain verbatim. If fewer than five are worthwhile, create only those; if none are safe, call done.", false);
598
619
  for (let invocation = 1; !completedWithDone && invocation < SUMMARIZER_MAX_INVOCATIONS; invocation++) {
599
620
  const continuingTruncatedResponse = stopReason === "length";
600
- stopReason = await runOnce(continuingTruncatedResponse ? "Continue working." : summarizerContinue(drafts.size, invocation), !continuingTruncatedResponse);
621
+ stopReason = await runOnce(continuingTruncatedResponse ? "Continue working from the incomplete analysis above. Do not restart it." : summarizerContinue(drafts.size, invocation), !continuingTruncatedResponse);
601
622
  }
602
623
  } catch (error) {
603
624
  debugLog("summarizer.error", { error: error instanceof Error ? error.message : String(error), acceptedSummaries: drafts.size });