@matthewfl/pi-contemplator 0.0.1 → 0.0.3

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.3",
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;
@@ -134,7 +143,8 @@ export class Contemplator {
134
143
  private resumedReviewIds = new Set<string>();
135
144
  private reviewerSessions = new Map<string, ReviewerSession>();
136
145
  private deliveredProbeIds = new Set<string>();
137
- private requeuedProbeIds = new Set<string>();
146
+ /** Probe ids passed to pi.sendMessage by this live extension runtime. */
147
+ private queuedProbeIds = new Set<string>();
138
148
  private sessionGeneration = 0;
139
149
  private latestCtx: MemoryUpdateCtx | undefined;
140
150
  private turnsSinceRun = 0;
@@ -144,12 +154,19 @@ export class Contemplator {
144
154
 
145
155
  register(): void {
146
156
  this.runtime.setMemoryUpdateListener((ctx) => this.withDebugContext(ctx, () => this.observeTurn(ctx)));
147
- const restoreSessionBranch = (_event: any, ctx: ExtensionContext) => {
157
+ this.pi.on("session_start", (event: any, ctx: ExtensionContext) => {
158
+ this.sessionGeneration++;
159
+ // AgentSession preserves its steering queue across extension reloads. An
160
+ // undelivered tracking entry therefore still has a live queued message;
161
+ // restoring it here would enqueue the same probe a second time.
162
+ const reload = event?.reason === "reload";
163
+ this.restore(ctx, true, reload, reload);
164
+ });
165
+ this.pi.on("session_tree", (_event: any, ctx: ExtensionContext) => {
148
166
  this.sessionGeneration++;
149
- this.restore(ctx, true);
150
- };
151
- this.pi.on("session_start", restoreSessionBranch);
152
- this.pi.on("session_tree", restoreSessionBranch);
167
+ // Pending steering messages remain queued while navigating the tree.
168
+ this.restore(ctx, true, true);
169
+ });
153
170
  this.pi.on("session_shutdown", () => {
154
171
  this.sessionGeneration++;
155
172
  this.history = [];
@@ -163,7 +180,7 @@ export class Contemplator {
163
180
  this.resumedReviewIds.clear();
164
181
  this.reviewerSessions.clear();
165
182
  this.deliveredProbeIds.clear();
166
- this.requeuedProbeIds.clear();
183
+ this.queuedProbeIds.clear();
167
184
  this.latestCtx = undefined;
168
185
  this.turnsSinceRun = 0;
169
186
  this.restoredTipId = undefined;
@@ -211,7 +228,7 @@ export class Contemplator {
211
228
  }, fn);
212
229
  }
213
230
 
214
- private restore(ctx: MemoryUpdateCtx, resetTracking = false): void {
231
+ private restore(ctx: MemoryUpdateCtx, resetTracking = false, retainQueuedIds = false, skipUndeliveredRestore = false): void {
215
232
  this.latestCtx = ctx;
216
233
  const entries = ctx.sessionManager.getBranch() as Entry[];
217
234
  const tipId = entries.at(-1)?.id;
@@ -220,7 +237,7 @@ export class Contemplator {
220
237
  this.history = [];
221
238
  if (resetTracking) {
222
239
  this.deliveredProbeIds.clear();
223
- this.requeuedProbeIds.clear();
240
+ if (!retainQueuedIds) this.queuedProbeIds.clear();
224
241
  this.inFlightReviewIds.clear();
225
242
  this.resolvingReviewIds.clear();
226
243
  this.resumedReviewIds.clear();
@@ -252,17 +269,42 @@ export class Contemplator {
252
269
  }
253
270
  }
254
271
  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") });
272
+ const state = entry.data as { version?: unknown; reviewRequestId?: unknown; scope?: unknown; history?: unknown; messageEntryIds?: unknown };
273
+ if (typeof state.reviewRequestId === "string" && (state.scope === "workflow" || state.scope === "software")) {
274
+ if (state.version === 1 && Array.isArray(state.history)) {
275
+ // Backward compatibility for the old, expensive full-transcript snapshots.
276
+ this.reviewerSessions.set(state.reviewRequestId, {
277
+ scope: state.scope,
278
+ history: state.history.filter((message): message is AgentMessage => !!message && typeof message === "object"),
279
+ checkpointEntryId: entry.id,
280
+ messageEntryIds: [],
281
+ foldedEntryIds: new Set(),
282
+ });
283
+ } else if (state.version === 2 && Array.isArray(state.messageEntryIds) && state.messageEntryIds.every((id) => typeof id === "string")) {
284
+ // V2 checkpoints contain references only. The referenced state/messages
285
+ // have already been folded while walking this append-only branch.
286
+ const session = this.reviewerSessions.get(state.reviewRequestId) ?? { scope: state.scope, history: [], messageEntryIds: [], foldedEntryIds: new Set() };
287
+ session.checkpointEntryId = entry.id;
288
+ session.messageEntryIds = [];
289
+ this.reviewerSessions.set(state.reviewRequestId, session);
290
+ }
258
291
  }
259
292
  }
260
293
  if (entry.customType === OM_REVIEWER_MESSAGE && entry.data && typeof entry.data === "object") {
261
294
  const data = entry.data as { reviewRequestId?: unknown; scope?: unknown; message?: unknown };
262
295
  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);
296
+ let session = this.reviewerSessions.get(data.reviewRequestId);
297
+ if (!session) {
298
+ session = { scope: data.scope, history: [], messageEntryIds: [], foldedEntryIds: new Set() };
299
+ this.reviewerSessions.set(data.reviewRequestId, session);
300
+ }
301
+ // restore() re-walks the whole branch whenever the tip moves
302
+ // (turn_end), so fold each message entry at most once per live session.
303
+ if (!session.foldedEntryIds.has(entry.id)) {
304
+ session.foldedEntryIds.add(entry.id);
305
+ session.history.push(data.message as AgentMessage);
306
+ session.messageEntryIds.push(entry.id);
307
+ }
266
308
  }
267
309
  }
268
310
  if (entry.customType === CONTEMPLATOR_SUGGESTION && entry.data && typeof entry.data === "object") {
@@ -278,8 +320,7 @@ export class Contemplator {
278
320
  }
279
321
  this.restoredTipId = tipId;
280
322
  for (const [probeId, question] of undeliveredSuggestions) {
281
- if (queuedProbeIds.has(probeId) || this.requeuedProbeIds.has(probeId)) continue;
282
- this.requeuedProbeIds.add(probeId);
323
+ if (skipUndeliveredRestore || queuedProbeIds.has(probeId) || this.queuedProbeIds.has(probeId)) continue;
283
324
  this.queueProbe(ctx, question, "restore", probeId);
284
325
  }
285
326
  if (resetTracking) void this.resumePendingReviews(ctx);
@@ -537,6 +578,10 @@ export class Contemplator {
537
578
  display: false,
538
579
  details: { version: 1, question, source, probeId },
539
580
  }, { deliverAs: "steer", triggerTurn: false });
581
+ // sendMessage queues synchronously. Mark every source (not only restore)
582
+ // before a later turn_end can rebuild state from the still-undelivered
583
+ // tracking entry and enqueue this probe again.
584
+ this.queuedProbeIds.add(probeId);
540
585
  this.pi.appendEntry(CONTEMPLATOR_SUGGESTION, { version: 1, suggestion: question, delivered: false, source, probeId });
541
586
  this.markTipPersisted(ctx);
542
587
  debugLog("contemplator.suggestion_queued", {
@@ -572,7 +617,7 @@ export class Contemplator {
572
617
  this.resumedReviewIds.add(request.id);
573
618
  this.inFlightReviewIds.add(request.id);
574
619
  if (key) this.inFlightReviewKeys.add(key);
575
- const session = this.reviewerSessions.get(request.id) ?? { scope: request.scope, history: options.history ?? [] };
620
+ const session = this.reviewerSessions.get(request.id) ?? { scope: request.scope, history: options.history ?? [], messageEntryIds: [], foldedEntryIds: new Set() };
576
621
  this.reviewerSessions.set(request.id, session);
577
622
  const task = this.runtime.launchReviewTask(ctx, async () => {
578
623
  try {
@@ -586,9 +631,12 @@ export class Contemplator {
586
631
  if (sessionGeneration !== this.sessionGeneration || !this.reviewIsPending(ctx, request.id)) return;
587
632
  for (const message of messages) {
588
633
  session.history.push(message);
589
- this.pi.appendEntry(OM_REVIEWER_MESSAGE, { version: 1, reviewRequestId: request.id, scope: request.scope, message });
634
+ const entryId = this.appendEntryWithId(ctx, OM_REVIEWER_MESSAGE, { version: 1, reviewRequestId: request.id, scope: request.scope, message }, request.id);
635
+ if (entryId) {
636
+ session.foldedEntryIds.add(entryId);
637
+ session.messageEntryIds.push(entryId);
638
+ }
590
639
  }
591
- if (messages.length > 0) this.markTipPersisted(ctx);
592
640
  },
593
641
  });
594
642
  if (sessionGeneration !== this.sessionGeneration) {
@@ -643,10 +691,17 @@ export class Contemplator {
643
691
 
644
692
  private persistReviewerStates(ctx: MemoryUpdateCtx): void {
645
693
  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 });
694
+ if (session.messageEntryIds.length === 0) continue;
695
+ const checkpointEntryId = this.appendEntryWithId(ctx, OM_REVIEWER_STATE, {
696
+ version: 2,
697
+ reviewRequestId,
698
+ scope: session.scope,
699
+ previousStateEntryId: session.checkpointEntryId,
700
+ messageEntryIds: session.messageEntryIds,
701
+ }, reviewRequestId);
702
+ if (checkpointEntryId) session.checkpointEntryId = checkpointEntryId;
703
+ session.messageEntryIds = [];
648
704
  }
649
- if (this.reviewerSessions.size > 0) this.markTipPersisted(ctx);
650
705
  }
651
706
 
652
707
  private async resumePendingReviews(ctx: MemoryUpdateCtx): Promise<void> {
@@ -669,8 +724,27 @@ export class Contemplator {
669
724
  }
670
725
  }
671
726
 
672
- private markTipPersisted(ctx: MemoryUpdateCtx): void {
727
+ private markTipPersisted(ctx: MemoryUpdateCtx): string | undefined {
673
728
  this.restoredTipId = (ctx.sessionManager.getBranch() as Entry[]).at(-1)?.id;
729
+ return this.restoredTipId;
730
+ }
731
+
732
+ /**
733
+ * Append a custom entry and return the id of the entry just written, or
734
+ * undefined when it cannot be attributed. pi.appendEntry() does not return
735
+ * the entry id, so the branch is diffed around the synchronous append and the
736
+ * candidate is matched by customType + reviewRequestId. This never credits a
737
+ * concurrently-appended foreign entry reached via the branch tail. A failed
738
+ * attribution is harmless: restore rebuilds transcripts from the durable
739
+ * om.reviewer.message entries themselves and only skips a checkpoint.
740
+ */
741
+ private appendEntryWithId(ctx: MemoryUpdateCtx, customType: string, data: Record<string, unknown>, reviewRequestId: string): string | undefined {
742
+ const branch = ctx.sessionManager.getBranch() as readonly Entry[];
743
+ const before = branch.length;
744
+ this.pi.appendEntry(customType, data);
745
+ const added = (ctx.sessionManager.getBranch() as readonly Entry[]).slice(before);
746
+ const own = added.find((entry) => entry.customType === customType && (entry.data as { reviewRequestId?: unknown } | undefined)?.reviewRequestId === reviewRequestId);
747
+ return own?.id;
674
748
  }
675
749
 
676
750
  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";