@matthewfl/pi-contemplator 0.0.1 → 0.0.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.0.1",
3
+ "version": "0.0.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",
@@ -27,7 +27,16 @@ type Intervention =
27
27
  | { kind: "probe"; question: string }
28
28
  | { kind: "review"; request: Omit<StructuralReviewRequest, "createdAt" | "requestedBy"> };
29
29
 
30
- type ReviewerSession = { scope: StructuralReviewRequest["scope"]; history: AgentMessage[] };
30
+ type ReviewerSession = {
31
+ scope: StructuralReviewRequest["scope"];
32
+ history: AgentMessage[];
33
+ /** Latest compact checkpoint; older transcript content stays in referenced append-only entries. */
34
+ checkpointEntryId?: string;
35
+ /** Reviewer message entries appended since checkpointEntryId. */
36
+ messageEntryIds: string[];
37
+ /** Message-entry ids already folded into `history` in this live session (restore re-walk guard). */
38
+ foldedEntryIds: Set<string>;
39
+ };
31
40
 
32
41
  type QueueStructuralReviewOptions = {
33
42
  ctx: MemoryUpdateCtx;
@@ -252,17 +261,42 @@ export class Contemplator {
252
261
  }
253
262
  }
254
263
  if (entry.customType === OM_REVIEWER_STATE && entry.data && typeof entry.data === "object") {
255
- const state = entry.data as { reviewRequestId?: unknown; scope?: unknown; history?: unknown };
256
- if (typeof state.reviewRequestId === "string" && (state.scope === "workflow" || state.scope === "software") && Array.isArray(state.history)) {
257
- this.reviewerSessions.set(state.reviewRequestId, { scope: state.scope, history: state.history.filter((message): message is AgentMessage => !!message && typeof message === "object") });
264
+ const state = entry.data as { version?: unknown; reviewRequestId?: unknown; scope?: unknown; history?: unknown; messageEntryIds?: unknown };
265
+ if (typeof state.reviewRequestId === "string" && (state.scope === "workflow" || state.scope === "software")) {
266
+ if (state.version === 1 && Array.isArray(state.history)) {
267
+ // Backward compatibility for the old, expensive full-transcript snapshots.
268
+ this.reviewerSessions.set(state.reviewRequestId, {
269
+ scope: state.scope,
270
+ history: state.history.filter((message): message is AgentMessage => !!message && typeof message === "object"),
271
+ checkpointEntryId: entry.id,
272
+ messageEntryIds: [],
273
+ foldedEntryIds: new Set(),
274
+ });
275
+ } else if (state.version === 2 && Array.isArray(state.messageEntryIds) && state.messageEntryIds.every((id) => typeof id === "string")) {
276
+ // V2 checkpoints contain references only. The referenced state/messages
277
+ // have already been folded while walking this append-only branch.
278
+ const session = this.reviewerSessions.get(state.reviewRequestId) ?? { scope: state.scope, history: [], messageEntryIds: [], foldedEntryIds: new Set() };
279
+ session.checkpointEntryId = entry.id;
280
+ session.messageEntryIds = [];
281
+ this.reviewerSessions.set(state.reviewRequestId, session);
282
+ }
258
283
  }
259
284
  }
260
285
  if (entry.customType === OM_REVIEWER_MESSAGE && entry.data && typeof entry.data === "object") {
261
286
  const data = entry.data as { reviewRequestId?: unknown; scope?: unknown; message?: unknown };
262
287
  if (typeof data.reviewRequestId === "string" && (data.scope === "workflow" || data.scope === "software") && data.message && typeof data.message === "object") {
263
- const session = this.reviewerSessions.get(data.reviewRequestId) ?? { scope: data.scope, history: [] };
264
- session.history.push(data.message as AgentMessage);
265
- this.reviewerSessions.set(data.reviewRequestId, session);
288
+ let session = this.reviewerSessions.get(data.reviewRequestId);
289
+ if (!session) {
290
+ session = { scope: data.scope, history: [], messageEntryIds: [], foldedEntryIds: new Set() };
291
+ this.reviewerSessions.set(data.reviewRequestId, session);
292
+ }
293
+ // restore() re-walks the whole branch whenever the tip moves
294
+ // (turn_end), so fold each message entry at most once per live session.
295
+ if (!session.foldedEntryIds.has(entry.id)) {
296
+ session.foldedEntryIds.add(entry.id);
297
+ session.history.push(data.message as AgentMessage);
298
+ session.messageEntryIds.push(entry.id);
299
+ }
266
300
  }
267
301
  }
268
302
  if (entry.customType === CONTEMPLATOR_SUGGESTION && entry.data && typeof entry.data === "object") {
@@ -572,7 +606,7 @@ export class Contemplator {
572
606
  this.resumedReviewIds.add(request.id);
573
607
  this.inFlightReviewIds.add(request.id);
574
608
  if (key) this.inFlightReviewKeys.add(key);
575
- const session = this.reviewerSessions.get(request.id) ?? { scope: request.scope, history: options.history ?? [] };
609
+ const session = this.reviewerSessions.get(request.id) ?? { scope: request.scope, history: options.history ?? [], messageEntryIds: [], foldedEntryIds: new Set() };
576
610
  this.reviewerSessions.set(request.id, session);
577
611
  const task = this.runtime.launchReviewTask(ctx, async () => {
578
612
  try {
@@ -586,9 +620,12 @@ export class Contemplator {
586
620
  if (sessionGeneration !== this.sessionGeneration || !this.reviewIsPending(ctx, request.id)) return;
587
621
  for (const message of messages) {
588
622
  session.history.push(message);
589
- this.pi.appendEntry(OM_REVIEWER_MESSAGE, { version: 1, reviewRequestId: request.id, scope: request.scope, message });
623
+ const entryId = this.appendEntryWithId(ctx, OM_REVIEWER_MESSAGE, { version: 1, reviewRequestId: request.id, scope: request.scope, message }, request.id);
624
+ if (entryId) {
625
+ session.foldedEntryIds.add(entryId);
626
+ session.messageEntryIds.push(entryId);
627
+ }
590
628
  }
591
- if (messages.length > 0) this.markTipPersisted(ctx);
592
629
  },
593
630
  });
594
631
  if (sessionGeneration !== this.sessionGeneration) {
@@ -643,10 +680,17 @@ export class Contemplator {
643
680
 
644
681
  private persistReviewerStates(ctx: MemoryUpdateCtx): void {
645
682
  for (const [reviewRequestId, session] of this.reviewerSessions) {
646
- if (session.history.length === 0) continue;
647
- this.pi.appendEntry(OM_REVIEWER_STATE, { version: 1, reviewRequestId, scope: session.scope, history: session.history });
683
+ if (session.messageEntryIds.length === 0) continue;
684
+ const checkpointEntryId = this.appendEntryWithId(ctx, OM_REVIEWER_STATE, {
685
+ version: 2,
686
+ reviewRequestId,
687
+ scope: session.scope,
688
+ previousStateEntryId: session.checkpointEntryId,
689
+ messageEntryIds: session.messageEntryIds,
690
+ }, reviewRequestId);
691
+ if (checkpointEntryId) session.checkpointEntryId = checkpointEntryId;
692
+ session.messageEntryIds = [];
648
693
  }
649
- if (this.reviewerSessions.size > 0) this.markTipPersisted(ctx);
650
694
  }
651
695
 
652
696
  private async resumePendingReviews(ctx: MemoryUpdateCtx): Promise<void> {
@@ -669,8 +713,27 @@ export class Contemplator {
669
713
  }
670
714
  }
671
715
 
672
- private markTipPersisted(ctx: MemoryUpdateCtx): void {
716
+ private markTipPersisted(ctx: MemoryUpdateCtx): string | undefined {
673
717
  this.restoredTipId = (ctx.sessionManager.getBranch() as Entry[]).at(-1)?.id;
718
+ return this.restoredTipId;
719
+ }
720
+
721
+ /**
722
+ * Append a custom entry and return the id of the entry just written, or
723
+ * undefined when it cannot be attributed. pi.appendEntry() does not return
724
+ * the entry id, so the branch is diffed around the synchronous append and the
725
+ * candidate is matched by customType + reviewRequestId. This never credits a
726
+ * concurrently-appended foreign entry reached via the branch tail. A failed
727
+ * attribution is harmless: restore rebuilds transcripts from the durable
728
+ * om.reviewer.message entries themselves and only skips a checkpoint.
729
+ */
730
+ private appendEntryWithId(ctx: MemoryUpdateCtx, customType: string, data: Record<string, unknown>, reviewRequestId: string): string | undefined {
731
+ const branch = ctx.sessionManager.getBranch() as readonly Entry[];
732
+ const before = branch.length;
733
+ this.pi.appendEntry(customType, data);
734
+ const added = (ctx.sessionManager.getBranch() as readonly Entry[]).slice(before);
735
+ const own = added.find((entry) => entry.customType === customType && (entry.data as { reviewRequestId?: unknown } | undefined)?.reviewRequestId === reviewRequestId);
736
+ return own?.id;
674
737
  }
675
738
 
676
739
  private async compactHistory(model: Model<any>, apiKey: string, headers: Record<string, string> | undefined, sessionGeneration: number): Promise<void> {
@@ -5,7 +5,7 @@ export const OM_REVIEW_REQUEST = "om.review.request";
5
5
  export const OM_REVIEW_RESULT = "om.review.result";
6
6
  /** Persisted assistant/tool output from a short-lived structural reviewer. */
7
7
  export const OM_REVIEWER_MESSAGE = "om.reviewer.message";
8
- /** Snapshot of a reviewer transcript retained across primary-session compaction. */
8
+ /** Compact checkpoint referencing reviewer transcript entries across primary-session compaction. */
9
9
  export const OM_REVIEWER_STATE = "om.reviewer.state";
10
10
  /** Compact proposal notice queued for the primary agent. */
11
11
  export const OM_REVIEWER_NOTICE = "om.reviewer.notice";