@matthewfl/pi-contemplator 0.1.6 → 0.1.8

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.6",
3
+ "version": "0.1.8",
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",
@@ -41,7 +41,7 @@
41
41
  "typecheck": "tsc --noEmit",
42
42
  "test": "npm run test:unit && npm run test:e2e",
43
43
  "test:unit": "vitest run",
44
- "test:e2e": "node tests/e2e/rpc-contemplator.mjs && node tests/e2e/rpc-summarizer.mjs && node tests/e2e/rpc-delivery.mjs && node tests/e2e/rpc-restore-review.mjs && node tests/e2e/rpc-compaction.mjs && node tests/e2e/rpc-compaction-resilience.mjs && node tests/e2e/rpc-memory-edges.mjs && node tests/e2e/rpc-observer-length.mjs && node tests/e2e/rpc-routing-isolation.mjs"
44
+ "test:e2e": "node tests/e2e/rpc-contemplator.mjs && node tests/e2e/rpc-contemplator-history.mjs && node tests/e2e/rpc-summarizer.mjs && node tests/e2e/rpc-delivery.mjs && node tests/e2e/rpc-restore-review.mjs && node tests/e2e/rpc-compaction.mjs && node tests/e2e/rpc-compaction-resilience.mjs && node tests/e2e/rpc-memory-edges.mjs && node tests/e2e/rpc-observer-length.mjs && node tests/e2e/rpc-routing-isolation.mjs"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@earendil-works/pi-agent-core": "*",
@@ -50,10 +50,10 @@
50
50
  "@earendil-works/pi-tui": "*"
51
51
  },
52
52
  "devDependencies": {
53
- "@earendil-works/pi-agent-core": "^0.84.3",
54
- "@earendil-works/pi-ai": "^0.84.3",
55
- "@earendil-works/pi-coding-agent": "^0.84.3",
56
- "@earendil-works/pi-tui": "^0.84.3",
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",
57
57
  "@types/node": "^22.0.0",
58
58
  "typebox": "^1.1.38",
59
59
  "typescript": "^5.6.0",
@@ -2,7 +2,7 @@ import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage,
2
2
  import { Type, type Message, type Model } from "@earendil-works/pi-ai";
3
3
  import type { Static } from "typebox";
4
4
  import { streamSimple } from "@earendil-works/pi-ai/compat";
5
- import { generateSummaryWithUsage } from "@earendil-works/pi-coding-agent";
5
+ import { estimateTokens as estimateAgentMessageTokens, generateSummaryWithUsage } from "@earendil-works/pi-coding-agent";
6
6
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
7
7
  import { Box, Text } from "@earendil-works/pi-tui";
8
8
  import { agentActiveTimeMs, assistantOutputTokens, assistantToolCallCount, fullProjection, isReviewRequestEntry, isReviewResultEntry, OM_AGENT_ACTIVITY, OM_REVIEWER_MESSAGE, OM_REVIEWER_NOTICE, OM_REVIEWER_STATE, OM_REVIEW_REQUEST, OM_REVIEW_RESULT, rawTokensSinceObservationCoverage, recallMemorySources, type Entry, type ReviewResult, type StructuralReviewRequest } from "../../session-ledger/index.js";
@@ -19,9 +19,68 @@ import { buildContemplatorSystemPrompt } from "./prompts.js";
19
19
  import { runStructuralReview } from "../reviewer/agent.js";
20
20
  import { createWorkerStallWatchdog } from "../../worker-watchdog.js";
21
21
 
22
+ const CONTEMPLATOR_HISTORY_FALLBACK_TRIGGER_TOKENS = 20_000;
23
+ const CONTEMPLATOR_HISTORY_MAX_TRIGGER_TOKENS = 40_000;
24
+ const CONTEMPLATOR_HISTORY_CONTEXT_FRACTION = 0.25;
25
+ const CONTEMPLATOR_HISTORY_KEEP_RECENT_TOKENS = 12_000;
26
+ const CONTEMPLATOR_HISTORY_SUMMARY_RESERVE_TOKENS = 16_000;
27
+ 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.";
28
+
29
+ function contemplatorMessageTokens(message: AgentMessage): number {
30
+ try {
31
+ return Math.max(1, estimateAgentMessageTokens(message));
32
+ } catch {
33
+ return Math.max(1, Math.ceil(JSON.stringify(message).length / 4));
34
+ }
35
+ }
36
+
37
+ function contemplatorHistoryTokens(history: readonly AgentMessage[]): number {
38
+ return history.reduce((total, message) => total + contemplatorMessageTokens(message), 0);
39
+ }
40
+
41
+ function contemplatorHistoryTriggerTokens(model: Model<any>): number {
42
+ const contextWindow = Number(model.contextWindow);
43
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) return CONTEMPLATOR_HISTORY_FALLBACK_TRIGGER_TOKENS;
44
+ return Math.max(1, Math.min(CONTEMPLATOR_HISTORY_MAX_TRIGGER_TOKENS, Math.floor(contextWindow * CONTEMPLATOR_HISTORY_CONTEXT_FRACTION)));
45
+ }
46
+
47
+ /** Return a user-message boundary that leaves approximately targetTokens recent. */
48
+ function recentHistoryStart(history: readonly AgentMessage[], entryIds: readonly (string | undefined)[], targetTokens: number): number | undefined {
49
+ let tokens = 0;
50
+ let start = history.length;
51
+ while (start > 0 && tokens < targetTokens) {
52
+ start--;
53
+ tokens += contemplatorMessageTokens(history[start]);
54
+ }
55
+ while (start > 0 && history[start].role !== "user") start--;
56
+ // Retained messages are durable pointers, not copied payloads. If a legacy
57
+ // snapshot supplied messages without entry ids, retain only the newer complete
58
+ // updates whose original om.contemplator.message entries can be referenced.
59
+ let lastMissingId = -1;
60
+ for (let index = start; index < entryIds.length; index++) {
61
+ if (entryIds[index] === undefined) lastMissingId = index;
62
+ }
63
+ if (lastMissingId >= 0) {
64
+ start = lastMissingId + 1;
65
+ while (start < history.length && history[start].role !== "user") start++;
66
+ }
67
+ return start > 0 && start < history.length ? start : undefined;
68
+ }
69
+
70
+ /** Pick a smaller complete oldest prefix after a length-truncated first attempt. */
71
+ function smallerPrefixEnd(history: readonly AgentMessage[], initialEnd: number): number | undefined {
72
+ const target = Math.max(1, Math.floor(contemplatorHistoryTokens(history.slice(0, initialEnd)) / 2));
73
+ let tokens = 0;
74
+ for (let index = 0; index < initialEnd; index++) {
75
+ tokens += contemplatorMessageTokens(history[index]);
76
+ const next = index + 1;
77
+ if (tokens >= target && next < initialEnd && history[next].role === "user") return next;
78
+ }
79
+ return undefined;
80
+ }
81
+
22
82
  interface PendingUpdate {
23
83
  observations: string[];
24
- summaries: string[];
25
84
  reviews: string[];
26
85
  mainAgentOutputTokens: number;
27
86
  mainAgentToolCalls: number;
@@ -212,6 +271,8 @@ export function createRequestReviewTool(
212
271
 
213
272
  export class Contemplator {
214
273
  private history: AgentMessage[] = [];
274
+ /** Ledger entry id for each private-history message; used by compact checkpoints to retain a suffix without copying it. */
275
+ private historyEntryIds: Array<string | undefined> = [];
215
276
  private pending: PendingUpdate | undefined;
216
277
  private running = false;
217
278
  /** Invalidates stale/hard-timed-out flush finalizers across session changes. */
@@ -219,7 +280,6 @@ export class Contemplator {
219
280
  /** Bounds retries of one poisoned memory update so future updates can run. */
220
281
  private consecutiveFlushFailures = 0;
221
282
  private seenObservationIds = new Set<string>();
222
- private seenSummaryIds = new Set<string>();
223
283
  private seenReviewIds = new Set<string>();
224
284
  private inFlightReviewKeys = new Set<string>();
225
285
  private inFlightReviewIds = new Set<string>();
@@ -302,9 +362,9 @@ export class Contemplator {
302
362
  this.consecutiveFlushFailures = 0;
303
363
  this.agentActiveSince = undefined;
304
364
  this.history = [];
365
+ this.historyEntryIds = [];
305
366
  this.pending = undefined;
306
367
  this.seenObservationIds.clear();
307
- this.seenSummaryIds.clear();
308
368
  this.seenReviewIds.clear();
309
369
  this.inFlightReviewKeys.clear();
310
370
  this.inFlightReviewIds.clear();
@@ -321,7 +381,6 @@ export class Contemplator {
321
381
  this.runtime.contemplatorState = {
322
382
  running: false,
323
383
  pendingObservations: 0,
324
- pendingSummaries: 0,
325
384
  pendingReviews: 0,
326
385
  responsesSinceRun: 0,
327
386
  waitingFor: "idle",
@@ -431,7 +490,6 @@ export class Contemplator {
431
490
  ...this.runtime.contemplatorState,
432
491
  running: this.running,
433
492
  pendingObservations: this.pending?.observations.length ?? 0,
434
- pendingSummaries: this.pending?.summaries.length ?? 0,
435
493
  pendingReviews: this.pending?.reviews.length ?? 0,
436
494
  responsesSinceRun: this.turnsSinceRun,
437
495
  waitingFor,
@@ -446,6 +504,8 @@ export class Contemplator {
446
504
  if (this.running && !resetTracking) return;
447
505
  if (tipId === this.restoredTipId && !resetTracking) return;
448
506
  this.history = [];
507
+ this.historyEntryIds = [];
508
+ const historyMessagesByEntryId = new Map<string, AgentMessage>();
449
509
  let resetProjection: ReturnType<typeof fullProjection> | undefined;
450
510
  if (resetTracking) {
451
511
  this.deliveredProbeIds.clear();
@@ -457,7 +517,6 @@ export class Contemplator {
457
517
  this.reviewerSessions.clear();
458
518
  resetProjection = fullProjection(entries);
459
519
  this.seenObservationIds.clear();
460
- this.seenSummaryIds.clear();
461
520
  this.seenReviewIds.clear();
462
521
  this.pending = undefined;
463
522
  this.turnsSinceRun = 0;
@@ -465,7 +524,6 @@ export class Contemplator {
465
524
  this.runtime.contemplatorState = {
466
525
  running: false,
467
526
  pendingObservations: 0,
468
- pendingSummaries: 0,
469
527
  pendingReviews: 0,
470
528
  responsesSinceRun: 0,
471
529
  waitingFor: "idle",
@@ -475,14 +533,27 @@ export class Contemplator {
475
533
  for (const entry of entries) {
476
534
  if (entry.customType === CONTEMPLATOR_STATE && entry.data && typeof entry.data === "object") {
477
535
  const state = entry.data as { history?: unknown };
478
- if (Array.isArray(state.history)) this.history = state.history.filter((message): message is AgentMessage => !!message && typeof message === "object");
536
+ if (Array.isArray(state.history)) {
537
+ this.history = state.history.filter((message): message is AgentMessage => !!message && typeof message === "object");
538
+ this.historyEntryIds = this.history.map(() => undefined);
539
+ }
479
540
  }
480
541
  if (entry.customType === CONTEMPLATOR_MESSAGE && entry.data && typeof entry.data === "object") {
481
- const data = entry.data as { message?: unknown; compacted?: unknown };
542
+ const data = entry.data as { message?: unknown; compacted?: unknown; retainedMessageEntryIds?: unknown };
482
543
  const message = data.message;
483
544
  if (message && typeof message === "object") {
484
- if (data.compacted === true) this.history = [message as AgentMessage];
485
- else this.history.push(message as AgentMessage);
545
+ const typedMessage = message as AgentMessage;
546
+ historyMessagesByEntryId.set(entry.id, typedMessage);
547
+ if (data.compacted === true) {
548
+ const retainedIds = Array.isArray(data.retainedMessageEntryIds)
549
+ ? data.retainedMessageEntryIds.filter((id): id is string => typeof id === "string" && historyMessagesByEntryId.has(id))
550
+ : [];
551
+ this.history = [typedMessage, ...retainedIds.map((id) => historyMessagesByEntryId.get(id)!)];
552
+ this.historyEntryIds = [entry.id, ...retainedIds];
553
+ } else {
554
+ this.history.push(typedMessage);
555
+ this.historyEntryIds.push(entry.id);
556
+ }
486
557
  }
487
558
  }
488
559
  if (entry.customType === OM_REVIEWER_STATE && entry.data && typeof entry.data === "object") {
@@ -547,12 +618,10 @@ export class Contemplator {
547
618
  for (const id of memoryReferenceIds(text)) coveredIds.add(id);
548
619
  }
549
620
  this.seenObservationIds = new Set(resetProjection.observations.filter((item) => coveredIds.has(item.id)).map((item) => item.id));
550
- this.seenSummaryIds = new Set(resetProjection.summaries.filter((item) => coveredIds.has(item.id)).map((item) => item.id));
551
621
  this.seenReviewIds = new Set((resetProjection.reviews ?? []).filter((item) => coveredIds.has(item.id)).map((item) => item.id));
552
622
  const unprocessedObservations = resetProjection.observations.length - this.seenObservationIds.size;
553
- const unprocessedSummaries = resetProjection.summaries.length - this.seenSummaryIds.size;
554
623
  const unprocessedReviews = (resetProjection.reviews?.length ?? 0) - this.seenReviewIds.size;
555
- if (unprocessedReviews > 0 || unprocessedObservations >= this.runtime.config.contemplatorMinNewObservations || unprocessedSummaries >= this.runtime.config.contemplatorMinNewSummaries) {
624
+ if (unprocessedReviews > 0 || unprocessedObservations >= this.runtime.config.contemplatorMinNewObservations) {
556
625
  this.turnsSinceRun = this.runtime.config.contemplatorMinTurns;
557
626
  }
558
627
  }
@@ -607,31 +676,24 @@ export class Contemplator {
607
676
  }
608
677
  const projection = fullProjection(branchEntries);
609
678
  const observations = projection.observations.map((item) => `[${item.id}] ${item.content}`);
610
- const summaries = projection.summaries.map((item) => `[${item.id}] ${item.content}`);
611
679
  const reviews = projection.reviews ?? [];
612
680
  const newObservationItems = projection.observations.filter((item) => !this.seenObservationIds.has(item.id));
613
- const newSummaryItems = projection.summaries.filter((item) => !this.seenSummaryIds.has(item.id));
614
681
  const newReviewItems = reviews.filter((item) => !this.seenReviewIds.has(item.id));
615
682
  const newObservations = newObservationItems.map((item) => `[${item.id}] ${item.content}`);
616
- const newSummaries = newSummaryItems.map((item) => `[${item.id}] ${item.content}`);
617
683
  const newReviews = newReviewItems.map(reviewSummaryLine);
618
684
  for (const item of newObservationItems) this.seenObservationIds.add(item.id);
619
- for (const item of newSummaryItems) this.seenSummaryIds.add(item.id);
620
685
  for (const item of newReviewItems) this.seenReviewIds.add(item.id);
621
686
  debugLog("contemplator.update", {
622
687
  observationCount: observations.length,
623
- summaryCount: summaries.length,
624
688
  newObservationCount: newObservations.length,
625
- newSummaryCount: newSummaries.length,
626
689
  newReviewCount: newReviews.length,
627
690
  turnsSinceRun: this.turnsSinceRun,
628
691
  pending: this.pending !== undefined,
629
692
  running: this.running,
630
693
  });
631
- if (newObservations.length > 0 || newSummaries.length > 0 || newReviews.length > 0) {
694
+ if (newObservations.length > 0 || newReviews.length > 0) {
632
695
  this.pending = {
633
696
  observations: mergeMemoryLines(this.pending?.observations ?? [], newObservations),
634
- summaries: mergeMemoryLines(this.pending?.summaries ?? [], newSummaries),
635
697
  reviews: mergeMemoryLines(this.pending?.reviews ?? [], newReviews),
636
698
  mainAgentOutputTokens: assistantOutputTokens(branchEntries),
637
699
  mainAgentToolCalls: assistantToolCallCount(branchEntries),
@@ -648,7 +710,7 @@ export class Contemplator {
648
710
  this.pending.mainAgentOutputTokens = assistantOutputTokens(branchEntries);
649
711
  this.pending.mainAgentToolCalls = assistantToolCallCount(branchEntries);
650
712
  this.pending.mainAgentActiveTimeMs = agentActiveTimeMs(branchEntries);
651
- const enoughMemories = this.pending.reviews.length > 0 || this.pending.observations.length >= this.runtime.config.contemplatorMinNewObservations || this.pending.summaries.length >= this.runtime.config.contemplatorMinNewSummaries;
713
+ const enoughMemories = this.pending.reviews.length > 0 || this.pending.observations.length >= this.runtime.config.contemplatorMinNewObservations;
652
714
  if (this.probeCooldownPendingIds.size > 0) {
653
715
  this.publishState("probe");
654
716
  debugLog("contemplator.waiting", { reason: "probe_delivery", pendingProbeCount: this.probeCooldownPendingIds.size });
@@ -661,14 +723,12 @@ export class Contemplator {
661
723
  turnsSinceRun: this.turnsSinceRun,
662
724
  minTurns: this.runtime.config.contemplatorMinTurns,
663
725
  minNewObservations: this.runtime.config.contemplatorMinNewObservations,
664
- minNewSummaries: this.runtime.config.contemplatorMinNewSummaries,
665
726
  });
666
727
  return;
667
728
  }
668
729
  this.publishState(this.running ? "running" : "ready");
669
730
  debugLog("contemplator.triggered", {
670
731
  pendingObservationCount: this.pending.observations.length,
671
- pendingSummaryCount: this.pending.summaries.length,
672
732
  pendingReviewCount: this.pending.reviews.length,
673
733
  turnsSinceRun: this.turnsSinceRun,
674
734
  });
@@ -705,7 +765,6 @@ export class Contemplator {
705
765
  this.publishState("running", { lastStartedAt: startedAt, lastError: undefined });
706
766
  debugLog("contemplator.start", {
707
767
  newObservationCount: update.observations.length,
708
- newSummaryCount: update.summaries.length,
709
768
  newReviewCount: update.reviews.length,
710
769
  historyMessageCount: this.history.length,
711
770
  });
@@ -727,14 +786,13 @@ export class Contemplator {
727
786
  const pending = this.pending as PendingUpdate | undefined;
728
787
  this.pending = {
729
788
  observations: mergeMemoryLines(pending?.observations ?? [], update.observations),
730
- summaries: mergeMemoryLines(pending?.summaries ?? [], update.summaries),
731
789
  reviews: mergeMemoryLines(pending?.reviews ?? [], update.reviews),
732
790
  mainAgentOutputTokens: update.mainAgentOutputTokens,
733
791
  mainAgentToolCalls: update.mainAgentToolCalls,
734
792
  mainAgentActiveTimeMs: update.mainAgentActiveTimeMs,
735
793
  };
736
794
  } else {
737
- debugLog("contemplator.poisoned_update_released", { reason: resolved.reason, observationCount: update.observations.length, summaryCount: update.summaries.length, reviewCount: update.reviews.length });
795
+ debugLog("contemplator.poisoned_update_released", { reason: resolved.reason, observationCount: update.observations.length, reviewCount: update.reviews.length });
738
796
  this.consecutiveFlushFailures = 0;
739
797
  }
740
798
  this.turnsSinceRun = 0; // Back off until fresh primary responses arrive; never retry every checkpoint.
@@ -759,7 +817,6 @@ export class Contemplator {
759
817
  const reviewerEnabled = this.runtime.config.reviewerEnabled;
760
818
  const updateSections: string[] = [];
761
819
  if (update.observations.length > 0) updateSections.push(`OBSERVATIONS:\n${update.observations.join("\n")}`);
762
- if (update.summaries.length > 0) updateSections.push(`SUMMARIES:\n${update.summaries.join("\n")}`);
763
820
  if (update.reviews.length > 0) updateSections.push(`REVIEWS:\n${update.reviews.join("\n")}`);
764
821
  const updateBody = updateSections.length > 0 ? updateSections.join("\n\n") : "(no new memories)";
765
822
  const finalActionNames = reviewerEnabled
@@ -883,7 +940,7 @@ export class Contemplator {
883
940
  for (const message of runMessages) {
884
941
  if (message.role !== "user" && message.role !== "assistant") continue;
885
942
  this.history.push(message);
886
- this.pi.appendEntry(CONTEMPLATOR_MESSAGE, { version: 1, message });
943
+ this.historyEntryIds.push(this.appendContemplatorHistoryEntry(ctx, { version: 1, message }));
887
944
  promptPersisted = true;
888
945
  this.markTipPersisted(ctx);
889
946
  }
@@ -913,7 +970,13 @@ export class Contemplator {
913
970
  }
914
971
  if (sessionGeneration === this.sessionGeneration) {
915
972
  workerWatchdog.progress();
916
- await workerWatchdog.race(this.compactHistory(resolved.model as Model<any>, resolved.apiKey, resolved.headers, sessionGeneration, flushEpoch));
973
+ try {
974
+ await workerWatchdog.race(this.compactHistory(ctx, resolved.model as Model<any>, resolved.apiKey, resolved.headers, sessionGeneration, flushEpoch, workerWatchdog.signal));
975
+ } catch (compactionError) {
976
+ // The intervention and its durable messages are already complete. Private
977
+ // history maintenance must never relabel that successful work as failed.
978
+ debugLog("contemplator.compaction_postponed", { reason: compactionError instanceof Error ? compactionError.message : String(compactionError) });
979
+ }
917
980
  }
918
981
  } catch (error) {
919
982
  failed = true;
@@ -925,14 +988,13 @@ export class Contemplator {
925
988
  const pending = this.pending as PendingUpdate | undefined;
926
989
  this.pending = {
927
990
  observations: mergeMemoryLines(pending?.observations ?? [], update.observations),
928
- summaries: mergeMemoryLines(pending?.summaries ?? [], update.summaries),
929
991
  reviews: mergeMemoryLines(pending?.reviews ?? [], update.reviews),
930
992
  mainAgentOutputTokens: update.mainAgentOutputTokens,
931
993
  mainAgentToolCalls: update.mainAgentToolCalls,
932
994
  mainAgentActiveTimeMs: update.mainAgentActiveTimeMs,
933
995
  };
934
996
  } else {
935
- debugLog("contemplator.poisoned_update_released", { reason: failureMessage, observationCount: update.observations.length, summaryCount: update.summaries.length, reviewCount: update.reviews.length });
997
+ debugLog("contemplator.poisoned_update_released", { reason: failureMessage, observationCount: update.observations.length, reviewCount: update.reviews.length });
936
998
  this.consecutiveFlushFailures = 0;
937
999
  }
938
1000
  this.turnsSinceRun = 0; // Back off until fresh primary responses arrive; never retry every checkpoint.
@@ -950,8 +1012,7 @@ export class Contemplator {
950
1012
  const waitingForProbe = this.probeCooldownPendingIds.size > 0;
951
1013
  const pendingHasEnoughMemories = this.pending !== undefined && (
952
1014
  this.pending.reviews.length > 0 ||
953
- this.pending.observations.length >= this.runtime.config.contemplatorMinNewObservations ||
954
- this.pending.summaries.length >= this.runtime.config.contemplatorMinNewSummaries
1015
+ this.pending.observations.length >= this.runtime.config.contemplatorMinNewObservations
955
1016
  );
956
1017
  const waitingFor = waitingForProbe
957
1018
  ? "probe"
@@ -1157,6 +1218,24 @@ export class Contemplator {
1157
1218
  }
1158
1219
  }
1159
1220
 
1221
+ /**
1222
+ * Append one private-history entry and attribute its synchronous ledger id.
1223
+ * Pi does not return the id from appendEntry(), so prefer the exact data
1224
+ * object and accept a cloned payload only when exactly one matching entry was
1225
+ * appended. Ambiguity returns undefined rather than pointing at another
1226
+ * writer's message.
1227
+ */
1228
+ private appendContemplatorHistoryEntry(ctx: MemoryUpdateCtx, data: Record<string, unknown>): string | undefined {
1229
+ const branch = ctx.sessionManager.getBranch() as readonly Entry[];
1230
+ const before = branch.length;
1231
+ this.pi.appendEntry(CONTEMPLATOR_MESSAGE, data);
1232
+ const candidates = (ctx.sessionManager.getBranch() as readonly Entry[])
1233
+ .slice(before)
1234
+ .filter((entry) => entry.customType === CONTEMPLATOR_MESSAGE);
1235
+ return candidates.find((entry) => entry.data === data)?.id
1236
+ ?? (candidates.length === 1 ? candidates[0].id : undefined);
1237
+ }
1238
+
1160
1239
  private markTipPersisted(ctx: MemoryUpdateCtx): string | undefined {
1161
1240
  this.restoredTipId = (ctx.sessionManager.getBranch() as Entry[]).at(-1)?.id;
1162
1241
  return this.restoredTipId;
@@ -1180,27 +1259,73 @@ export class Contemplator {
1180
1259
  return own?.id;
1181
1260
  }
1182
1261
 
1183
- private async compactHistory(model: Model<any>, apiKey: string, headers: Record<string, string> | undefined, sessionGeneration: number, flushEpoch: number): Promise<void> {
1184
- const serializedLength = this.history.reduce((total, message) => total + JSON.stringify(message).length, 0);
1185
- if (this.history.length < 12 || serializedLength < 60_000) return;
1186
- const previousMessageCount = this.history.length;
1262
+ private async compactHistory(ctx: MemoryUpdateCtx, model: Model<any>, apiKey: string, headers: Record<string, string> | undefined, sessionGeneration: number, flushEpoch: number, signal?: AbortSignal): Promise<void> {
1263
+ const history = this.history.slice();
1264
+ const historyEntryIds = this.historyEntryIds.slice();
1265
+ const historyTokens = contemplatorHistoryTokens(history);
1266
+ const triggerTokens = contemplatorHistoryTriggerTokens(model);
1267
+ if (history.length < 12 || historyTokens < triggerTokens) return;
1268
+
1269
+ const keepRecentTokens = Math.min(CONTEMPLATOR_HISTORY_KEEP_RECENT_TOKENS, Math.max(1, Math.floor(triggerTokens * 0.5)));
1270
+ const initialPrefixEnd = recentHistoryStart(history, historyEntryIds, keepRecentTokens);
1271
+ if (initialPrefixEnd === undefined) return;
1272
+ const previousMessageCount = history.length;
1187
1273
  debugLog("contemplator.compaction_start", {
1188
1274
  historyMessageCount: previousMessageCount,
1189
- serializedLength,
1275
+ historyTokens,
1276
+ triggerTokens,
1277
+ keepRecentTokens,
1278
+ prefixMessageCount: initialPrefixEnd,
1279
+ retainedMessageCount: history.length - initialPrefixEnd,
1190
1280
  });
1191
- const history = this.history.slice();
1192
- const summaryWithUsage = await generateSummaryWithUsage(history as AgentMessage[], model, 4_000, apiKey, headers);
1281
+
1282
+ const summarizePrefix = async (prefixEnd: number) => generateSummaryWithUsage(
1283
+ history.slice(0, prefixEnd),
1284
+ model,
1285
+ CONTEMPLATOR_HISTORY_SUMMARY_RESERVE_TOKENS,
1286
+ apiKey,
1287
+ headers,
1288
+ signal,
1289
+ CONTEMPLATOR_HISTORY_SUMMARY_INSTRUCTIONS,
1290
+ );
1291
+
1292
+ let prefixEnd = initialPrefixEnd;
1293
+ let summaryWithUsage: Awaited<ReturnType<typeof generateSummaryWithUsage>>;
1294
+ try {
1295
+ summaryWithUsage = await summarizePrefix(prefixEnd);
1296
+ } catch (error) {
1297
+ const failure = error instanceof Error ? error.message : String(error);
1298
+ const fallbackEnd = /generation hit the token cap/i.test(failure)
1299
+ ? smallerPrefixEnd(history, initialPrefixEnd)
1300
+ : undefined;
1301
+ if (fallbackEnd === undefined) {
1302
+ debugLog("contemplator.compaction_postponed", { reason: failure, historyTokens, prefixMessageCount: prefixEnd });
1303
+ return;
1304
+ }
1305
+ prefixEnd = fallbackEnd;
1306
+ debugLog("contemplator.compaction_retry_smaller_prefix", { reason: failure, prefixMessageCount: prefixEnd, retainedMessageCount: history.length - prefixEnd });
1307
+ try {
1308
+ summaryWithUsage = await summarizePrefix(prefixEnd);
1309
+ } catch (fallbackError) {
1310
+ debugLog("contemplator.compaction_postponed", {
1311
+ reason: fallbackError instanceof Error ? fallbackError.message : String(fallbackError),
1312
+ historyTokens,
1313
+ prefixMessageCount: prefixEnd,
1314
+ });
1315
+ return;
1316
+ }
1317
+ }
1318
+
1193
1319
  this.runtime.recordAgentUsage(summaryWithUsage.usage);
1194
1320
  if (sessionGeneration !== this.sessionGeneration || flushEpoch !== this.flushEpoch) {
1195
1321
  debugLog("contemplator.compaction_stale", { reason: "session_or_branch_changed" });
1196
1322
  return;
1197
1323
  }
1198
- const summary = summaryWithUsage.text;
1199
1324
  const summaryModel = model as Model<any> & { api?: unknown; provider?: string; id?: string };
1200
1325
  const summaryUsage = summaryWithUsage.usage;
1201
- this.history = [{
1326
+ const summaryMessage = {
1202
1327
  role: "assistant",
1203
- content: [{ type: "text", text: `Previous contemplator context summary:\n${summary}` }],
1328
+ content: [{ type: "text", text: `Previous contemplator context summary:\n${summaryWithUsage.text}` }],
1204
1329
  api: summaryModel.api,
1205
1330
  provider: summaryModel.provider ?? "unknown",
1206
1331
  model: summaryModel.id ?? "contemplator",
@@ -1214,12 +1339,37 @@ export class Contemplator {
1214
1339
  },
1215
1340
  stopReason: "stop",
1216
1341
  timestamp: Date.now(),
1217
- } as AgentMessage];
1218
- this.pi.appendEntry(CONTEMPLATOR_MESSAGE, { version: 1, compacted: true, message: this.history[0] });
1342
+ } as AgentMessage;
1343
+ // A flush currently owns private-history mutation while this awaits the
1344
+ // model, and session/tree movement invalidates the generation above. Still,
1345
+ // preserve any future append-only writer rather than replacing a stale
1346
+ // snapshot and silently dropping messages. Non-append mutation is unsafe to
1347
+ // merge, so postpone and let a later pass compact the live transcript.
1348
+ const liveHistory = this.history;
1349
+ const liveHistoryEntryIds = this.historyEntryIds;
1350
+ const snapshotIsLivePrefix = liveHistory.length >= history.length
1351
+ && history.every((message, index) => liveHistory[index] === message && liveHistoryEntryIds[index] === historyEntryIds[index]);
1352
+ if (!snapshotIsLivePrefix) {
1353
+ debugLog("contemplator.compaction_postponed", { reason: "private history changed during compaction" });
1354
+ return;
1355
+ }
1356
+ const retainedMessages = liveHistory.slice(prefixEnd);
1357
+ const retainedMessageEntryIds = liveHistoryEntryIds.slice(prefixEnd).filter((id): id is string => typeof id === "string");
1358
+ if (retainedMessageEntryIds.length !== retainedMessages.length) {
1359
+ debugLog("contemplator.compaction_postponed", { reason: "retained history lacked durable entry ids", retainedMessageCount: retainedMessages.length, retainedReferenceCount: retainedMessageEntryIds.length });
1360
+ return;
1361
+ }
1362
+ const checkpoint = { version: 2, compacted: true, message: summaryMessage, retainedMessageEntryIds };
1363
+ const checkpointEntryId = this.appendContemplatorHistoryEntry(ctx, checkpoint);
1364
+ this.history = [summaryMessage, ...retainedMessages];
1365
+ this.historyEntryIds = [checkpointEntryId, ...retainedMessageEntryIds];
1366
+ this.markTipPersisted(ctx);
1219
1367
  debugLog("contemplator.compaction_complete", {
1220
1368
  previousMessageCount,
1221
1369
  newMessageCount: this.history.length,
1222
- summaryLength: summary.length,
1370
+ prefixMessageCount: prefixEnd,
1371
+ retainedMessageCount: retainedMessages.length,
1372
+ summaryLength: summaryWithUsage.text.length,
1223
1373
  });
1224
1374
  }
1225
1375
  }
@@ -6,7 +6,7 @@ export function buildContemplatorSystemPrompt(
6
6
 
7
7
  Neither you nor the primary agent should be assumed to know the correct solution. You are jointly exploring a problem space from different perspectives. The primary agent interacts with the actual environment and carries out the work. You maintain a longer-term view of the reasoning, evidence, assumptions, alternatives, unresolved questions${reviewerEnabled ? ", and recurring structural patterns" : ""} that emerge over time.
8
8
 
9
- You receive incremental observations and cited summaries produced by other agent loops. Some memories summarize user messages. Pay extra attention to memories about the user’s intent, priorities, constraints, corrections, and desired outcome.
9
+ You receive incremental observations about primary-agent activity. Pay extra attention to memories about the user’s intent, priorities, constraints, corrections, and desired outcome. Older summarized memories remain available through search_memories and recall when you need historical context, but summary maintenance is not itself a new event.
10
10
 
11
11
  You see only the memory ledger, not the primary agent’s live activity. Your understanding may be incomplete or slightly stale. Do not infer inactivity, failure, or lack of progress from missing recent results. A result may simply not have reached memory yet.
12
12
 
@@ -59,7 +59,7 @@ function renderMessage(message: StoredMessage, compacted: boolean): string {
59
59
  }
60
60
 
61
61
  function liveStateLine(state: ContemplatorRunState): string {
62
- const pending = `${state.pendingObservations} observations / ${state.pendingSummaries} summaries / ${state.pendingReviews} reviews pending`;
62
+ const pending = `${state.pendingObservations} observations / ${state.pendingReviews} reviews pending`;
63
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()}`;
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}`;
@@ -10,7 +10,7 @@ type ModelRegistryLike = {
10
10
  getAll(): Array<{ provider: string; id: string }>;
11
11
  find?(provider: string, id: string): { contextWindow?: number } | undefined;
12
12
  };
13
- type NumberSetting = "observeAfterTokens" | "compactAfterTokens" | "observerChunkMaxTokens" | "newMemoryPoolMaxTokens" | "oldMemoryPoolTargetTokens" | "agentMaxTurns" | "contemplatorMinNewObservations" | "contemplatorMinNewSummaries" | "contemplatorMinTurns" | "summarizerRetriggerTokens" | "summarizerSamplingThresholdTokens";
13
+ type NumberSetting = "observeAfterTokens" | "compactAfterTokens" | "observerChunkMaxTokens" | "newMemoryPoolMaxTokens" | "oldMemoryPoolTargetTokens" | "agentMaxTurns" | "contemplatorMinNewObservations" | "contemplatorMinTurns" | "summarizerRetriggerTokens" | "summarizerSamplingThresholdTokens";
14
14
  type BooleanSetting = "contemplatorEnabled" | "showContemplatorMessages" | "reviewerEnabled" | "summarizerEnabled" | "compactionObserverEnabled" | "showWorkerNotifications" | "passive" | "debugLog";
15
15
 
16
16
  function modelLabel(model: ConfiguredModel | undefined): string {
@@ -216,7 +216,6 @@ export function registerSettingsCommand(pi: ExtensionAPI, runtime: Runtime): voi
216
216
  `Contemplator model: ${hasOverride(settings, "contemplatorModel") ? modelLabel(runtime.config.contemplatorModel) : `${modelLabel(runtime.getDefaultConfig().contemplatorModel)} (default)`}`,
217
217
  `Show contemplator messages: ${scalarLabel(runtime, "showContemplatorMessages")}`,
218
218
  `Contemplator new-observation trigger (count): ${scalarLabel(runtime, "contemplatorMinNewObservations")}`,
219
- `Contemplator new-summary trigger (count): ${scalarLabel(runtime, "contemplatorMinNewSummaries")}`,
220
219
  `Contemplator response spacing (count): ${scalarLabel(runtime, "contemplatorMinTurns")}`,
221
220
  `Summarizer enabled: ${scalarLabel(runtime, "summarizerEnabled")}`,
222
221
  `New memory pool protection budget (tokens): ${scalarLabel(runtime, "newMemoryPoolMaxTokens")}`,
@@ -272,7 +271,6 @@ export function registerSettingsCommand(pi: ExtensionAPI, runtime: Runtime): voi
272
271
  ["New memory pool protection budget (tokens):", "newMemoryPoolMaxTokens", "New memory pool protection budget (tokens)"],
273
272
  ["Old memory pool target (tokens, advisory):", "oldMemoryPoolTargetTokens", "Old memory pool target (tokens, advisory)"],
274
273
  ["Contemplator new-observation trigger (count):", "contemplatorMinNewObservations", "Contemplator new-observation trigger (count)"],
275
- ["Contemplator new-summary trigger (count):", "contemplatorMinNewSummaries", "Contemplator new-summary trigger (count)"],
276
274
  ["Contemplator response spacing (count):", "contemplatorMinTurns", "Contemplator response spacing (count)"],
277
275
  ["Summarizer old-pool retrigger growth (tokens):", "summarizerRetriggerTokens", "Summarizer old-pool retrigger growth (tokens)"],
278
276
  ["Summarizer input cap before sampling (tokens):", "summarizerSamplingThresholdTokens", "Summarizer input cap before sampling (tokens)"],
@@ -130,7 +130,7 @@ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void
130
130
  `Cumulative agent time: ${formatDuration(agentActiveTimeMs(entries))}`,
131
131
  `Observe source during compaction: ${runtime.config.compactionObserverEnabled === false ? "disabled" : "enabled"}`,
132
132
  `Contemplator: ${runtime.config.contemplatorEnabled ? "enabled" : "disabled"}`,
133
- `Contemplator trigger: ${runtime.contemplatorState.pendingObservations} observations / ${runtime.contemplatorState.pendingSummaries} summaries / ${runtime.contemplatorState.pendingReviews} reviews pending; ${runtime.contemplatorState.responsesSinceRun} / ${runtime.config.contemplatorMinTurns} primary responses; ${contemplatorWaitingLabel(runtime.contemplatorState.waitingFor)}`,
133
+ `Contemplator trigger: ${runtime.contemplatorState.pendingObservations} observations / ${runtime.contemplatorState.pendingReviews} reviews pending; ${runtime.contemplatorState.responsesSinceRun} / ${runtime.config.contemplatorMinTurns} primary responses; ${contemplatorWaitingLabel(runtime.contemplatorState.waitingFor)}`,
134
134
  `Contemplator model: ${runtime.config.contemplatorModel ? `${runtime.config.contemplatorModel.provider}/${runtime.config.contemplatorModel.id}` : "current session model"}`,
135
135
  `Contemplator messages: ${runtime.config.showContemplatorMessages ? "visible" : "hidden"}`,
136
136
  `Structural reviewer: ${runtime.config.reviewerEnabled === false ? "disabled" : "enabled"}`,
package/src/config.ts CHANGED
@@ -60,7 +60,6 @@ export interface Config {
60
60
  /** Optional model override used only by short-lived structural reviewers. */
61
61
  reviewerModel?: ConfiguredModel;
62
62
  contemplatorMinNewObservations: number;
63
- contemplatorMinNewSummaries: number;
64
63
  /** Minimum primary-model responses after contemplator completion, or after delivery of its probe, before the next run. */
65
64
  contemplatorMinTurns: number;
66
65
  /** Stateless loss-aware summarizer for the old memory pool. */
@@ -87,7 +86,6 @@ export const DEFAULTS: Config = {
87
86
  showContemplatorMessages: true,
88
87
  reviewerEnabled: true,
89
88
  contemplatorMinNewObservations: 8,
90
- contemplatorMinNewSummaries: 1,
91
89
  contemplatorMinTurns: 10,
92
90
  summarizerEnabled: true,
93
91
  summarizerRetriggerTokens: 2_000,
@@ -210,7 +208,6 @@ function normalizeSettingsConfig(value: Record<string, unknown>): Partial<Config
210
208
  "oldMemoryPoolTargetTokens",
211
209
  "agentMaxTurns",
212
210
  "contemplatorMinNewObservations",
213
- "contemplatorMinNewSummaries",
214
211
  "contemplatorMinTurns",
215
212
  "summarizerRetriggerTokens",
216
213
  "summarizerSamplingThresholdTokens",
@@ -393,7 +393,8 @@ export function scheduleSummarizer(pi: ExtensionAPI, runtime: Runtime, ctx: Cons
393
393
  runtime.lastSummarizerRun = { ...runtime.lastSummarizerRun!, status: "completed", summary };
394
394
  debugLog("summarizer.appended", { summaries: result.commit.summaries.length, consumed: result.commit.metrics.consumedMemoryCount, sampled: result.sample?.sampled ?? false });
395
395
  if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(`pi-contemplator: summarizer completed — ${summary}`, "info");
396
- runtime.notifyMemoryUpdate(ctx);
396
+ // Summaries compact older memories; they are not new events and must not
397
+ // wake or enter the contemplator's incremental update stream.
397
398
  } else {
398
399
  successfullyCompleted = true;
399
400
  runtime.lastSummarizerRun = { ...runtime.lastSummarizerRun!, status: "completed", summary: "No safe summaries were created." };
package/src/runtime.ts CHANGED
@@ -25,7 +25,7 @@ export type SessionSettings = Partial<Pick<Config,
25
25
  | "compactAfterTokensMode" | "compactAfterTokensRatio"
26
26
  | "newMemoryPoolMaxTokens" | "oldMemoryPoolTargetTokens" | "agentMaxTurns"
27
27
  | "showWorkerNotifications" | "passive" | "compactionObserverEnabled" | "contemplatorEnabled" | "showContemplatorMessages" | "reviewerEnabled"
28
- | "contemplatorMinNewObservations" | "contemplatorMinNewSummaries" | "contemplatorMinTurns"
28
+ | "contemplatorMinNewObservations" | "contemplatorMinTurns"
29
29
  | "summarizerEnabled" | "summarizerRetriggerTokens" | "summarizerSamplingThresholdTokens"
30
30
  | "debugLog"
31
31
  >> & {
@@ -80,7 +80,6 @@ export interface SummarizerRunView {
80
80
  export interface ContemplatorRunState {
81
81
  running: boolean;
82
82
  pendingObservations: number;
83
- pendingSummaries: number;
84
83
  pendingReviews: number;
85
84
  /** Completed primary-model responses since the current completion/probe-delivery spacing anchor. */
86
85
  responsesSinceRun: number;
@@ -140,7 +139,7 @@ export function computeSessionSettings(entries: readonly unknown[]): SessionSett
140
139
  const numberKeys = [
141
140
  "observeAfterTokens", "observerChunkMaxTokens", "compactAfterTokens",
142
141
  "newMemoryPoolMaxTokens", "oldMemoryPoolTargetTokens", "agentMaxTurns",
143
- "contemplatorMinNewObservations", "contemplatorMinNewSummaries", "contemplatorMinTurns",
142
+ "contemplatorMinNewObservations", "contemplatorMinTurns",
144
143
  "summarizerRetriggerTokens", "summarizerSamplingThresholdTokens",
145
144
  ] as const;
146
145
  for (const key of booleanKeys) if (typeof data[key] === "boolean") restored[key] = data[key];
@@ -209,7 +208,6 @@ export class Runtime {
209
208
  contemplatorState: ContemplatorRunState = {
210
209
  running: false,
211
210
  pendingObservations: 0,
212
- pendingSummaries: 0,
213
211
  pendingReviews: 0,
214
212
  responsesSinceRun: 0,
215
213
  waitingFor: "idle",
@@ -299,7 +297,6 @@ export class Runtime {
299
297
  this.contemplatorState = {
300
298
  running: false,
301
299
  pendingObservations: 0,
302
- pendingSummaries: 0,
303
300
  pendingReviews: 0,
304
301
  responsesSinceRun: 0,
305
302
  waitingFor: "idle",