@matthewfl/pi-contemplator 0.1.3 → 0.1.5

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.3",
3
+ "version": "0.1.5",
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",
@@ -229,9 +229,11 @@ export class Contemplator {
229
229
  private deliveredProbeIds = new Set<string>();
230
230
  /** Probe ids passed to pi.sendMessage by this live extension runtime. */
231
231
  private queuedProbeIds = new Set<string>();
232
+ /** Probes whose provider-context delivery will establish the next response-spacing anchor. */
233
+ private probeCooldownPendingIds = new Set<string>();
232
234
  private sessionGeneration = 0;
233
235
  private latestCtx: MemoryUpdateCtx | undefined;
234
- /** Completed primary-model responses since the previous contemplator run. */
236
+ /** Completed primary-model responses since the current completion/probe-delivery spacing anchor. */
235
237
  private turnsSinceRun = 0;
236
238
  /** Used to avoid counting the final turn_end after its assistant message_end. */
237
239
  private assistantResponsesInCurrentTurn = 0;
@@ -311,6 +313,7 @@ export class Contemplator {
311
313
  this.reviewerSessions.clear();
312
314
  this.deliveredProbeIds.clear();
313
315
  this.queuedProbeIds.clear();
316
+ this.probeCooldownPendingIds.clear();
314
317
  this.latestCtx = undefined;
315
318
  this.turnsSinceRun = 0;
316
319
  this.assistantResponsesInCurrentTurn = 0;
@@ -359,9 +362,12 @@ export class Contemplator {
359
362
  });
360
363
  this.pi.on("context", (event: any, ctx: ExtensionContext) => {
361
364
  const deliveredMessages = event.messages?.filter((message: any) => message?.role === "custom" && message.customType === CONTEMPLATOR_SUGGESTION && typeof message.details?.probeId === "string") ?? [];
365
+ let cooldownAnchored = false;
362
366
  for (const delivered of deliveredMessages) {
363
367
  if (this.deliveredProbeIds.has(delivered.details.probeId)) continue;
364
368
  this.deliveredProbeIds.add(delivered.details.probeId);
369
+ this.probeCooldownPendingIds.delete(delivered.details.probeId);
370
+ cooldownAnchored = true;
365
371
  // Once Pi includes the probe in a provider context it is no longer in
366
372
  // either in-memory delivery queue. Keeping this id indefinitely caused
367
373
  // later tree restores to suppress a genuinely needed requeue.
@@ -375,6 +381,12 @@ export class Contemplator {
375
381
  this.markTipPersisted(ctx);
376
382
  debugLog("contemplator.suggestion_delivered", { probeId: delivered.details.probeId });
377
383
  }
384
+ if (cooldownAnchored) {
385
+ // Probe spacing begins only once Pi proves the probe reached an actual
386
+ // provider context. Responses generated before this point do not count.
387
+ this.turnsSinceRun = 0;
388
+ this.withDebugContext(ctx, () => this.observeTurn(ctx));
389
+ }
378
390
  });
379
391
  this.pi.on("turn_end", (_event: any, ctx: ExtensionContext) => {
380
392
  this.persistAgentActivity(ctx);
@@ -437,6 +449,7 @@ export class Contemplator {
437
449
  let resetProjection: ReturnType<typeof fullProjection> | undefined;
438
450
  if (resetTracking) {
439
451
  this.deliveredProbeIds.clear();
452
+ this.probeCooldownPendingIds.clear();
440
453
  if (!retainQueuedIds) this.queuedProbeIds.clear();
441
454
  this.inFlightReviewIds.clear();
442
455
  this.resolvingReviewIds.clear();
@@ -545,6 +558,9 @@ export class Contemplator {
545
558
  }
546
559
  this.restoredTipId = tipId;
547
560
  for (const [probeId, question] of undeliveredSuggestions) {
561
+ // An undelivered durable probe remains the cooldown anchor even when Pi's
562
+ // live queue survived an extension reload and must not be duplicated.
563
+ this.probeCooldownPendingIds.add(probeId);
548
564
  // A durable custom_message proves only that Pi inserted the probe at some
549
565
  // point; it does not prove an in-memory queue still owns it, and compaction
550
566
  // may have removed it from active model context. Suppress requeue only for
@@ -620,7 +636,7 @@ export class Contemplator {
620
636
  };
621
637
  }
622
638
  if (!this.pending) {
623
- this.publishState(this.running ? "running" : "idle");
639
+ this.publishState(this.running ? "running" : this.probeCooldownPendingIds.size > 0 ? "probe" : "idle");
624
640
  return;
625
641
  }
626
642
  // Activity values are cumulative send-time snapshots, not values frozen when
@@ -630,6 +646,11 @@ export class Contemplator {
630
646
  this.pending.mainAgentToolCalls = assistantToolCallCount(branchEntries);
631
647
  this.pending.mainAgentActiveTimeMs = agentActiveTimeMs(branchEntries);
632
648
  const enoughMemories = this.pending.reviews.length > 0 || this.pending.observations.length >= this.runtime.config.contemplatorMinNewObservations || this.pending.summaries.length >= this.runtime.config.contemplatorMinNewSummaries;
649
+ if (this.probeCooldownPendingIds.size > 0) {
650
+ this.publishState("probe");
651
+ debugLog("contemplator.waiting", { reason: "probe_delivery", pendingProbeCount: this.probeCooldownPendingIds.size });
652
+ return;
653
+ }
633
654
  if (!enoughMemories || this.turnsSinceRun < this.runtime.config.contemplatorMinTurns) {
634
655
  this.publishState(!enoughMemories ? "memories" : "responses");
635
656
  debugLog("contemplator.waiting", {
@@ -676,6 +697,7 @@ export class Contemplator {
676
697
  let failureMessage: string | undefined;
677
698
  let workerNotified = false;
678
699
  let promptPersisted = false;
700
+ let emittedProbeId: string | undefined;
679
701
  let workerWatchdog: ReturnType<typeof createWorkerStallWatchdog> | undefined;
680
702
  this.publishState("running", { lastStartedAt: startedAt, lastError: undefined });
681
703
  debugLog("contemplator.start", {
@@ -863,7 +885,7 @@ export class Contemplator {
863
885
  this.markTipPersisted(ctx);
864
886
  }
865
887
  }
866
- if (intervention?.kind === "probe" && sessionGeneration === this.sessionGeneration) this.queueProbe(ctx, intervention.question, "send_probe");
888
+ if (intervention?.kind === "probe" && sessionGeneration === this.sessionGeneration) emittedProbeId = this.queueProbe(ctx, intervention.question, "send_probe");
867
889
  if (intervention?.kind === "review" && this.runtime.config.reviewerEnabled && sessionGeneration === this.sessionGeneration) {
868
890
  const reviewerModel = await this.runtime.resolveModel({
869
891
  model: ctx.model,
@@ -917,13 +939,21 @@ export class Contemplator {
917
939
  if (flushEpoch !== this.flushEpoch) return;
918
940
  this.running = false;
919
941
  if (!failed) this.consecutiveFlushFailures = 0;
942
+ // Normal runs establish their spacing anchor at completion. A probe run
943
+ // instead anchors at provider-context delivery: if delivery already occurred
944
+ // during this run, retain responses counted since it; otherwise the pending
945
+ // probe gate blocks launches until the context event resets the counter.
946
+ if (emittedProbeId === undefined) this.turnsSinceRun = 0;
947
+ const waitingForProbe = this.probeCooldownPendingIds.size > 0;
920
948
  const pendingHasEnoughMemories = this.pending !== undefined && (
921
949
  this.pending.reviews.length > 0 ||
922
950
  this.pending.observations.length >= this.runtime.config.contemplatorMinNewObservations ||
923
951
  this.pending.summaries.length >= this.runtime.config.contemplatorMinNewSummaries
924
952
  );
925
- const waitingFor = !this.pending
926
- ? "idle"
953
+ const waitingFor = waitingForProbe
954
+ ? "probe"
955
+ : !this.pending
956
+ ? "idle"
927
957
  : !pendingHasEnoughMemories
928
958
  ? "memories"
929
959
  : this.turnsSinceRun < this.runtime.config.contemplatorMinTurns
@@ -948,7 +978,7 @@ export class Contemplator {
948
978
  }
949
979
  }
950
980
 
951
- private queueProbe(ctx: MemoryUpdateCtx, question: string, source: "send_probe" | "restore", existingProbeId?: string): void {
981
+ private queueProbe(ctx: MemoryUpdateCtx, question: string, source: "send_probe" | "restore", existingProbeId?: string): string {
952
982
  const probeId = existingProbeId ?? `${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
953
983
  // Persist intent before touching Pi's in-memory queue. A crash in between
954
984
  // leaves a recoverable pending probe rather than an invisible lost one.
@@ -967,6 +997,7 @@ export class Contemplator {
967
997
  // unrelated observer update or compaction callback cannot restore and enqueue
968
998
  // a duplicate while the original idle steer is still pending.
969
999
  this.queuedProbeIds.add(probeId);
1000
+ this.probeCooldownPendingIds.add(probeId);
970
1001
  this.pi.sendMessage({
971
1002
  customType: CONTEMPLATOR_SUGGESTION,
972
1003
  content: `Background contemplator probe (advisory):\n${question}\n\nReferenced memories can be reviewed using the recall tool.`,
@@ -981,6 +1012,7 @@ export class Contemplator {
981
1012
  triggerTurn: "omitted",
982
1013
  source,
983
1014
  });
1015
+ return probeId;
984
1016
  }
985
1017
 
986
1018
  private queueStructuralReview(options: QueueStructuralReviewOptions): void {
@@ -26,6 +26,7 @@ interface RunObserverArgs {
26
26
  thinkingLevel?: ModelThinkingLevel;
27
27
  recordUsage?: (usage: LlmUsageInput) => void;
28
28
  onProgress?: () => void;
29
+ onMessages?: (messages: readonly AgentMessage[]) => void;
29
30
  }
30
31
 
31
32
  const RelevanceSchema = Type.Union([
@@ -223,6 +224,10 @@ IMPORTANT: Now call record_observations to record the useful new observations fr
223
224
  messages: history.slice(),
224
225
  tools: [recordObservations as AgentTool<any>, doneTool],
225
226
  };
227
+ // Publish a live launch-local transcript for /om:view observer. Keep an
228
+ // invocation-local list because agentLoop owns its internal context copy.
229
+ let liveMessages: AgentMessage[] = [...history, prompt];
230
+ args.onMessages?.(liveMessages.slice());
226
231
  const invocationConfig: AgentLoopConfig = afterLength && reasoning
227
232
  ? { ...baseConfig, reasoning: "minimal" }
228
233
  : baseConfig;
@@ -230,7 +235,17 @@ IMPORTANT: Now call record_observations to record the useful new observations fr
230
235
  for await (const event of stream) {
231
236
  args.onProgress?.();
232
237
  logAgentStreamError("observer", event);
233
- const message = (event as { message?: { role?: string; stopReason?: string; errorMessage?: string } }).message;
238
+ const typedEvent = event as { type?: string; message?: AgentMessage & { role?: string; stopReason?: string; errorMessage?: string } };
239
+ const message = typedEvent.message;
240
+ if (message && message !== prompt && message.role !== "user") {
241
+ if (typedEvent.type === "message_start") liveMessages.push(message);
242
+ else if (typedEvent.type === "message_update" || typedEvent.type === "message_end") {
243
+ const index = liveMessages.map((item) => item.role).lastIndexOf(message.role);
244
+ if (index >= 0) liveMessages[index] = message;
245
+ else liveMessages.push(message);
246
+ }
247
+ args.onMessages?.(liveMessages.slice());
248
+ }
234
249
  if (message?.role === "assistant" && ["error", "aborted", "length"].includes(message.stopReason ?? "")) {
235
250
  terminalFailure = { stopReason: message.stopReason!, errorMessage: message.errorMessage };
236
251
  }
@@ -238,6 +253,8 @@ IMPORTANT: Now call record_observations to record the useful new observations fr
238
253
  const result = await stream.result();
239
254
  if (!Array.isArray(result)) return;
240
255
  history.push(...result);
256
+ liveMessages = history.slice();
257
+ args.onMessages?.(liveMessages);
241
258
  for (const message of result) {
242
259
  if (message.role === "assistant" && ["error", "aborted", "length"].includes(message.stopReason ?? "")) {
243
260
  terminalFailure = { stopReason: message.stopReason, errorMessage: message.errorMessage };
@@ -65,6 +65,8 @@ function liveStateLine(state: ContemplatorRunState): string {
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"
67
67
  ? "waiting for observer backlog"
68
+ : state.waitingFor === "probe"
69
+ ? "waiting for queued probe delivery"
68
70
  : state.waitingFor === "memories"
69
71
  ? "waiting for memory threshold"
70
72
  : state.waitingFor === "responses"
@@ -76,7 +78,7 @@ function liveStateLine(state: ContemplatorRunState): string {
76
78
  : state.waitingFor === "passive"
77
79
  ? "passive mode"
78
80
  : "idle";
79
- return `LIVE · ${reason} · ${pending} · ${state.responsesSinceRun} primary responses since last run\n${timing}${error}`;
81
+ return `LIVE · ${reason} · ${pending} · ${state.responsesSinceRun} primary responses since cooldown anchor\n${timing}${error}`;
80
82
  }
81
83
 
82
84
  export function renderContemplator(entries: Entry[], state?: ContemplatorRunState): string {
@@ -0,0 +1,59 @@
1
+ import type { ObserverRunView } from "../runtime.js";
2
+
3
+ const DIM = "\x1b[2m";
4
+ const RESET = "\x1b[0m";
5
+
6
+ type StoredMessage = { role?: unknown; content?: unknown };
7
+ type ContentPart = { type?: unknown; text?: unknown; thinking?: unknown; name?: unknown; arguments?: unknown; content?: unknown };
8
+
9
+ function renderValue(value: unknown): string {
10
+ if (typeof value === "string") return value;
11
+ if (value === undefined || value === null) return "";
12
+ return JSON.stringify(value, null, 2);
13
+ }
14
+
15
+ function renderContent(content: unknown): string {
16
+ if (typeof content === "string") return content;
17
+ if (!Array.isArray(content)) return renderValue(content);
18
+ return content.map((part: ContentPart) => {
19
+ if (part.type === "text") return typeof part.text === "string" ? part.text : "";
20
+ if (part.type === "thinking") {
21
+ const thinking = typeof part.thinking === "string" ? part.thinking : typeof part.text === "string" ? part.text : renderValue(part);
22
+ return `[thinking]\n${thinking}`;
23
+ }
24
+ if (part.type === "toolCall" || part.type === "tool_use" || part.type === "toolUse") {
25
+ const name = typeof part.name === "string" ? part.name : "unknown tool";
26
+ return `[tool call: ${name}${part.arguments === undefined ? "" : ` ${renderValue(part.arguments)}`}]`;
27
+ }
28
+ if (part.type === "toolResult" || part.type === "tool_result") return `[tool result]\n${renderValue(part.content)}`;
29
+ return `[${String(part.type ?? "content")}] ${renderValue(part)}`;
30
+ }).filter(Boolean).join("\n");
31
+ }
32
+
33
+ function estimateTokens(value: unknown): number {
34
+ return Math.max(1, Math.ceil(JSON.stringify(value).length / 4));
35
+ }
36
+
37
+ /** Render the currently active observer chunk, or the most recently completed chunk. */
38
+ export function renderObserver(run: ObserverRunView | undefined, now = Date.now()): string {
39
+ if (!run) return `${DIM}OBSERVER${RESET}\n\n${DIM}Observer has not run yet during this launch.${RESET}`;
40
+ const messages = run.messages.filter((message): message is StoredMessage => !!message && typeof message === "object");
41
+ const totalTokens = messages.reduce((sum, message) => sum + estimateTokens(message), 0);
42
+ const elapsedMs = Math.max(0, (run.completedAt ?? now) - run.startedAt);
43
+ const lines = [
44
+ `${DIM}OBSERVER · ${run.status} · ${messages.length} messages · ~${totalTokens.toLocaleString()} estimated transcript tokens${RESET}`,
45
+ `${DIM}Chunk ~${run.chunkTokens.toLocaleString()} tokens · backlog at start ~${run.backlogTokens.toLocaleString()} tokens · ${run.sourceEntryIds.length} source entr${run.sourceEntryIds.length === 1 ? "y" : "ies"}${RESET}`,
46
+ `${DIM}Started ${new Date(run.startedAt).toISOString()} · ${run.completedAt === undefined ? `running for ${Math.floor(elapsedMs / 1000)}s` : `ended ${new Date(run.completedAt).toISOString()} after ${Math.floor(elapsedMs / 1000)}s`}${RESET}`,
47
+ "",
48
+ ];
49
+ if (messages.length === 0) lines.push(`${DIM}(no observer messages captured yet)${RESET}`);
50
+ for (const [index, message] of messages.entries()) {
51
+ if (index > 0) lines.push("");
52
+ const role = typeof message.role === "string" ? message.role : "unknown";
53
+ lines.push(`${DIM}── ${role} · ~${estimateTokens(message).toLocaleString()} tokens ──${RESET}`);
54
+ lines.push(renderContent(message.content) || `${DIM}(empty message)${RESET}`);
55
+ }
56
+ if (run.summary) lines.push("", `${DIM}── Completion summary ──${RESET}`, run.summary);
57
+ if (run.error) lines.push("", `${DIM}── Failure ──${RESET}`, run.error);
58
+ return lines.join("\n");
59
+ }
@@ -43,6 +43,7 @@ function formatRunAge(timestamp: number): string {
43
43
  function contemplatorWaitingLabel(waitingFor: Runtime["contemplatorState"]["waitingFor"]): string {
44
44
  switch (waitingFor) {
45
45
  case "observer": return "waiting for observer backlog";
46
+ case "probe": return "waiting for queued probe delivery";
46
47
  case "memories": return "waiting for memory threshold";
47
48
  case "responses": return "waiting for response spacing";
48
49
  case "ready": return "ready to launch";
@@ -142,8 +143,8 @@ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void
142
143
  }
143
144
 
144
145
  lines.push("", "── Last worker runs ──");
145
- lines.push(`Last observer start: ${runtime.lastObserverStartedAt === undefined ? "not run this launch" : formatRunAge(runtime.lastObserverStartedAt)}`);
146
- lines.push(`Last observer end: ${runtime.lastObserverCompletedAt === undefined ? "not completed this launch" : formatRunAge(runtime.lastObserverCompletedAt)}`);
146
+ lines.push(`Observer chunk start: ${runtime.lastObserverStartedAt === undefined ? "not run this launch" : formatRunAge(runtime.lastObserverStartedAt)}`);
147
+ lines.push(`Observer chunk end: ${runtime.lastObserverCompletedAt === undefined ? runtime.lastObserverRun?.status === "running" ? "running" : "not completed this launch" : formatRunAge(runtime.lastObserverCompletedAt)}`);
147
148
  lines.push(`Last summarizer start: ${runtime.lastSummarizerStartedAt === undefined ? "not run this launch" : formatRunAge(runtime.lastSummarizerStartedAt)}`);
148
149
  lines.push(`Last summarizer end: ${runtime.lastSummarizerCompletedAt === undefined ? "not completed this launch" : formatRunAge(runtime.lastSummarizerCompletedAt)}`);
149
150
  lines.push(`Last contemplator start: ${runtime.contemplatorState.lastStartedAt === undefined ? "not run this launch" : formatRunAge(runtime.contemplatorState.lastStartedAt)}`);
@@ -4,6 +4,7 @@ import { copyTextToClipboard } from "../clipboard.js";
4
4
  import { renderContemplator, stripAnsi } from "./contemplator-view.js";
5
5
  import { renderReviewer } from "./reviewer-view.js";
6
6
  import { renderSummarizer } from "./summarizer-view.js";
7
+ import { renderObserver } from "./observer-view.js";
7
8
  import { executeRecall, formatRecallResultForTui } from "../tools/recall-observation.js";
8
9
  import {
9
10
  chronologicalMemories,
@@ -76,7 +77,7 @@ export function registerViewCommand(
76
77
 
77
78
  pi.registerCommand("om:view", {
78
79
  description:
79
- "Print and copy pi-contemplator memory content (visible, full, memory, contemplator, summarizer, reviewer, or reviews)",
80
+ "Print and copy pi-contemplator memory content (visible, full, memory, contemplator, observer, summarizer, reviewer, or reviews)",
80
81
  handler: async (args, ctx) => {
81
82
  runtime.ensureConfig(ctx.cwd);
82
83
  const entries = ctx.sessionManager.getBranch() as Entry[];
@@ -116,6 +117,18 @@ export function registerViewCommand(
116
117
  return;
117
118
  }
118
119
 
120
+ if (mode === "observer") {
121
+ const output = renderObserver(runtime.lastObserverRun);
122
+ const copied = await copyToClipboard(stripAnsi(output)).catch(() => false);
123
+ ctx.ui.notify(
124
+ `${output}
125
+
126
+ ${copied ? "Copied /om:view observer output to clipboard." : "Warning: failed to copy /om:view observer output to clipboard."}`,
127
+ "info",
128
+ );
129
+ return;
130
+ }
131
+
119
132
  if (mode === "summarizer") {
120
133
  const output = renderSummarizer(runtime.lastSummarizerRun);
121
134
  const copied = await copyToClipboard(stripAnsi(output)).catch(() => false);
@@ -154,7 +167,7 @@ export function registerViewCommand(
154
167
  }
155
168
 
156
169
  if (mode && mode !== "visible") {
157
- ctx.ui.notify("Usage: /om:view [visible|full|memory <id>|contemplator|summarizer|reviewer|reviews]", "info");
170
+ ctx.ui.notify("Usage: /om:view [visible|full|memory <id>|contemplator|observer|summarizer|reviewer|reviews]", "info");
158
171
  return;
159
172
  }
160
173
 
package/src/config.ts CHANGED
@@ -61,7 +61,7 @@ export interface Config {
61
61
  reviewerModel?: ConfiguredModel;
62
62
  contemplatorMinNewObservations: number;
63
63
  contemplatorMinNewSummaries: number;
64
- /** Minimum completed primary-model responses between contemplator runs. */
64
+ /** Minimum primary-model responses after contemplator completion, or after delivery of its probe, before the next run. */
65
65
  contemplatorMinTurns: number;
66
66
  /** Stateless loss-aware summarizer for the old memory pool. */
67
67
  summarizerEnabled: boolean;
@@ -227,7 +227,6 @@ export async function runConsolidationPipeline(
227
227
 
228
228
  const beforeFold = foldLedger(ctx.sessionManager.getBranch() as Entry[]);
229
229
  runtime.consolidationPhase = "observer";
230
- runtime.lastObserverStartedAt = Date.now();
231
230
  try {
232
231
  // A large backlog is drained in bounded, oldest-first chunks. The normal
233
232
  // trigger threshold controls when the batch stops; a static compaction
@@ -253,8 +252,6 @@ export async function runConsolidationPipeline(
253
252
  } catch (error) {
254
253
  debugLog("observer.error", { errorMessage: runtime.recordConsolidationStageError(ctx, "observer", error) });
255
254
  return;
256
- } finally {
257
- runtime.lastObserverCompletedAt = Date.now();
258
255
  }
259
256
  const afterFold = foldLedger(ctx.sessionManager.getBranch() as Entry[]);
260
257
  const beforeIds = new Set(beforeFold.observations.map((item) => item.id));
@@ -477,6 +474,20 @@ async function runObserverStage(
477
474
  priorObservations: priorObservations.length,
478
475
  });
479
476
 
477
+ const observerStartedAt = Date.now();
478
+ runtime.lastObserverStartedAt = observerStartedAt;
479
+ // Clear the previous end when a new chunk starts so /om:status never pairs
480
+ // this chunk's start with the preceding chunk's completion.
481
+ runtime.lastObserverCompletedAt = undefined;
482
+ runtime.lastObserverRun = {
483
+ startedAt: observerStartedAt,
484
+ status: "running",
485
+ messages: [],
486
+ chunkTokens,
487
+ backlogTokens: tokens,
488
+ sourceEntryIds: sourceEntryIds.slice(),
489
+ };
490
+ let acceptsObserverMessages = true;
480
491
  let observations;
481
492
  let failedMessage: string | undefined;
482
493
  const observerWatchdog = createWorkerStallWatchdog("observer");
@@ -493,6 +504,17 @@ async function runObserverStage(
493
504
  thinkingLevel: runtime.config.model?.thinking ?? "low",
494
505
  recordUsage: (usage) => runtime.recordAgentUsage(usage),
495
506
  onProgress: observerWatchdog.progress,
507
+ onMessages: (messages) => {
508
+ if (!acceptsObserverMessages || options.contextGeneration !== undefined && options.contextGeneration !== runtime.getContextGeneration()) return;
509
+ runtime.lastObserverRun = {
510
+ startedAt: observerStartedAt,
511
+ status: "running",
512
+ messages: messages.slice(),
513
+ chunkTokens,
514
+ backlogTokens: tokens,
515
+ sourceEntryIds: sourceEntryIds.slice(),
516
+ };
517
+ },
496
518
  signal: observerWatchdog.signal,
497
519
  }));
498
520
  } catch (error) {
@@ -509,8 +531,19 @@ async function runObserverStage(
509
531
  chunkTokens,
510
532
  });
511
533
  observations = undefined;
534
+ if (runtime.lastObserverRun?.startedAt === observerStartedAt) {
535
+ runtime.lastObserverRun = { ...runtime.lastObserverRun, status: "failed", error: failedMessage };
536
+ }
512
537
  } finally {
538
+ acceptsObserverMessages = false;
513
539
  observerWatchdog.dispose();
540
+ if (options.contextGeneration === undefined || options.contextGeneration === runtime.getContextGeneration()) {
541
+ const completedAt = Date.now();
542
+ runtime.lastObserverCompletedAt = completedAt;
543
+ if (runtime.lastObserverRun?.startedAt === observerStartedAt) {
544
+ runtime.lastObserverRun = { ...runtime.lastObserverRun, completedAt };
545
+ }
546
+ }
514
547
  }
515
548
  if (options.contextGeneration !== undefined && options.contextGeneration !== runtime.getContextGeneration()) {
516
549
  debugLog("observer.stale", { reason: "session_or_branch_changed" });
@@ -531,6 +564,15 @@ async function runObserverStage(
531
564
  });
532
565
  }
533
566
  const accepted = observations ?? [];
567
+ if (!failedMessage && runtime.lastObserverRun?.startedAt === observerStartedAt) {
568
+ runtime.lastObserverRun = {
569
+ ...runtime.lastObserverRun,
570
+ status: "completed",
571
+ summary: accepted.length > 0
572
+ ? `${accepted.length} observation${accepted.length === 1 ? "" : "s"} recorded; chunk covered through ${effectiveCoversUpToId}.`
573
+ : `No observations recorded; chunk covered through ${effectiveCoversUpToId}.`,
574
+ };
575
+ }
534
576
  const data = buildObservationsRecordedData(accepted, effectiveCoversUpToId);
535
577
  if (!data) return "continue";
536
578
  debugLog(failedMessage ? "observer.failed_coverage" : accepted.length > 0 ? "observer.records" : "observer.coverage_only", {
package/src/runtime.ts CHANGED
@@ -56,6 +56,18 @@ export interface MemoryUpdateCtx extends LaunchCtx {
56
56
 
57
57
  export type SettingsUpdate = Partial<SessionSettings>;
58
58
 
59
+ export interface ObserverRunView {
60
+ startedAt: number;
61
+ completedAt?: number;
62
+ status: "running" | "completed" | "failed";
63
+ messages: readonly unknown[];
64
+ chunkTokens: number;
65
+ backlogTokens: number;
66
+ sourceEntryIds: readonly string[];
67
+ summary?: string;
68
+ error?: string;
69
+ }
70
+
59
71
  export interface SummarizerRunView {
60
72
  startedAt: number;
61
73
  completedAt?: number;
@@ -70,9 +82,9 @@ export interface ContemplatorRunState {
70
82
  pendingObservations: number;
71
83
  pendingSummaries: number;
72
84
  pendingReviews: number;
73
- /** Completed primary-model responses since the previous contemplator run. */
85
+ /** Completed primary-model responses since the current completion/probe-delivery spacing anchor. */
74
86
  responsesSinceRun: number;
75
- waitingFor: "disabled" | "passive" | "observer" | "memories" | "responses" | "ready" | "running" | "idle";
87
+ waitingFor: "disabled" | "passive" | "observer" | "probe" | "memories" | "responses" | "ready" | "running" | "idle";
76
88
  lastStartedAt?: number;
77
89
  lastCompletedAt?: number;
78
90
  lastError?: string;
@@ -187,6 +199,8 @@ export class Runtime {
187
199
  lastObserverCompletedAt: number | undefined;
188
200
  lastSummarizerStartedAt: number | undefined;
189
201
  lastSummarizerCompletedAt: number | undefined;
202
+ /** Current or most recent observer chunk transcript in this launch/session context. */
203
+ lastObserverRun: ObserverRunView | undefined;
190
204
  /** Most recent summarizer transcript in this extension launch/session context. */
191
205
  lastSummarizerRun: SummarizerRunView | undefined;
192
206
  /** Launch-local liveness and trigger diagnostics published by the contemplator. */
@@ -277,6 +291,7 @@ export class Runtime {
277
291
  this.lastObserverCompletedAt = undefined;
278
292
  this.lastSummarizerStartedAt = undefined;
279
293
  this.lastSummarizerCompletedAt = undefined;
294
+ this.lastObserverRun = undefined;
280
295
  this.lastSummarizerRun = undefined;
281
296
  this.contemplatorState = {
282
297
  running: false,