@matthewfl/pi-contemplator 0.1.2 → 0.1.4

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.2",
3
+ "version": "0.1.4",
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-routing-isolation.mjs"
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"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@earendil-works/pi-agent-core": "*",
@@ -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([
@@ -216,17 +217,35 @@ IMPORTANT: Now call record_observations to record the useful new observations fr
216
217
  const loop = args.agentLoop ?? agentLoop;
217
218
  const history: AgentMessage[] = [];
218
219
  let terminalFailure: { stopReason: string; errorMessage?: string } | undefined;
219
- const runInvocation = async (prompt: Message): Promise<void> => {
220
+ let lengthRetryAttempted = false;
221
+ const runInvocation = async (prompt: Message, afterLength = false): Promise<void> => {
220
222
  const context: AgentContext = {
221
223
  systemPrompt: OBSERVER_SYSTEM,
222
224
  messages: history.slice(),
223
225
  tools: [recordObservations as AgentTool<any>, doneTool],
224
226
  };
225
- const stream = loop([prompt], context, baseConfig, signal, streamSimple);
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());
231
+ const invocationConfig: AgentLoopConfig = afterLength && reasoning
232
+ ? { ...baseConfig, reasoning: "minimal" }
233
+ : baseConfig;
234
+ const stream = loop([prompt], context, invocationConfig, signal, streamSimple);
226
235
  for await (const event of stream) {
227
236
  args.onProgress?.();
228
237
  logAgentStreamError("observer", event);
229
- 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
+ }
230
249
  if (message?.role === "assistant" && ["error", "aborted", "length"].includes(message.stopReason ?? "")) {
231
250
  terminalFailure = { stopReason: message.stopReason!, errorMessage: message.errorMessage };
232
251
  }
@@ -234,6 +253,8 @@ IMPORTANT: Now call record_observations to record the useful new observations fr
234
253
  const result = await stream.result();
235
254
  if (!Array.isArray(result)) return;
236
255
  history.push(...result);
256
+ liveMessages = history.slice();
257
+ args.onMessages?.(liveMessages);
237
258
  for (const message of result) {
238
259
  if (message.role === "assistant" && ["error", "aborted", "length"].includes(message.stopReason ?? "")) {
239
260
  terminalFailure = { stopReason: message.stopReason, errorMessage: message.errorMessage };
@@ -243,6 +264,23 @@ IMPORTANT: Now call record_observations to record the useful new observations fr
243
264
  };
244
265
 
245
266
  await runInvocation(initialPrompt);
267
+ if (accumulated.size === 0 && terminalFailure?.stopReason === "length") {
268
+ lengthRetryAttempted = true;
269
+ // A provider can impose a lower output ceiling than the advertised model
270
+ // maximum. agentLoop stops on `length` when no tool call was completed; it
271
+ // does not automatically send a continuation request. Preserve the partial
272
+ // response so the model can continue from work it already performed rather
273
+ // than paying to reproduce it, then append a short tool-focused instruction
274
+ // and reduce reasoning to minimal. A second length stop fails forward at the
275
+ // bounded-chunk level.
276
+ terminalFailure = undefined;
277
+ const retryPrompt: Message = {
278
+ role: "user",
279
+ content: [{ type: "text", text: "IMPORTANT: The previous response reached the provider output limit before recording anything. Continue from the work already above and call record_observations now instead of spending another response budget analyzing." }],
280
+ timestamp: Date.now(),
281
+ };
282
+ await runInvocation(retryPrompt, true);
283
+ }
246
284
  if (accumulated.size === 0 && !doneCalled && !terminalFailure && rejectedTotal === 0) {
247
285
  const reminder: Message = {
248
286
  role: "user",
@@ -257,7 +295,10 @@ IMPORTANT: Now call record_observations to record the useful new observations fr
257
295
  // zero-observation stop is also a valid empty result after the reminder;
258
296
  // actual stream failures, truncation, and malformed records still throw.
259
297
  if (accumulated.size === 0 && terminalFailure) {
260
- throw new ObserverStreamError(terminalFailure.stopReason, terminalFailure.errorMessage);
298
+ const detail = terminalFailure.stopReason === "length" && lengthRetryAttempted
299
+ ? `provider reached the output limit twice without recording an observation (effective max output request: ${baseConfig.maxTokens} tokens)`
300
+ : terminalFailure.errorMessage;
301
+ throw new ObserverStreamError(terminalFailure.stopReason, detail);
261
302
  }
262
303
  if (accumulated.size === 0 && rejectedTotal > 0) {
263
304
  throw new ObserverStreamError("invalid_observations", `${rejectedTotal} proposed observation${rejectedTotal === 1 ? " was" : "s were"} rejected`);
@@ -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
+ }
@@ -142,8 +142,8 @@ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void
142
142
  }
143
143
 
144
144
  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)}`);
145
+ lines.push(`Observer chunk start: ${runtime.lastObserverStartedAt === undefined ? "not run this launch" : formatRunAge(runtime.lastObserverStartedAt)}`);
146
+ lines.push(`Observer chunk end: ${runtime.lastObserverCompletedAt === undefined ? runtime.lastObserverRun?.status === "running" ? "running" : "not completed this launch" : formatRunAge(runtime.lastObserverCompletedAt)}`);
147
147
  lines.push(`Last summarizer start: ${runtime.lastSummarizerStartedAt === undefined ? "not run this launch" : formatRunAge(runtime.lastSummarizerStartedAt)}`);
148
148
  lines.push(`Last summarizer end: ${runtime.lastSummarizerCompletedAt === undefined ? "not completed this launch" : formatRunAge(runtime.lastSummarizerCompletedAt)}`);
149
149
  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
 
@@ -5,6 +5,7 @@ import { debugLog, withDebugLogContext } from "../debug-log.js";
5
5
  import { resolveObserverChunkMaxTokens } from "../config.js";
6
6
  import type { ResolveResult, Runtime } from "../runtime.js";
7
7
  import { createWorkerStallWatchdog } from "../worker-watchdog.js";
8
+ import { boundedMaxTokens, OBSERVER_AGENT_LOOP_MAX_TOKENS } from "../model-budget.js";
8
9
  import { serializeSourceAddressedBranchEntries } from "../serialize.js";
9
10
  import {
10
11
  OM_SUMMARIZER_COMMIT,
@@ -226,7 +227,6 @@ export async function runConsolidationPipeline(
226
227
 
227
228
  const beforeFold = foldLedger(ctx.sessionManager.getBranch() as Entry[]);
228
229
  runtime.consolidationPhase = "observer";
229
- runtime.lastObserverStartedAt = Date.now();
230
230
  try {
231
231
  // A large backlog is drained in bounded, oldest-first chunks. The normal
232
232
  // trigger threshold controls when the batch stops; a static compaction
@@ -252,8 +252,6 @@ export async function runConsolidationPipeline(
252
252
  } catch (error) {
253
253
  debugLog("observer.error", { errorMessage: runtime.recordConsolidationStageError(ctx, "observer", error) });
254
254
  return;
255
- } finally {
256
- runtime.lastObserverCompletedAt = Date.now();
257
255
  }
258
256
  const afterFold = foldLedger(ctx.sessionManager.getBranch() as Entry[]);
259
257
  const beforeIds = new Set(beforeFold.observations.map((item) => item.id));
@@ -466,6 +464,9 @@ async function runObserverStage(
466
464
  debugLog("observer.start", {
467
465
  tokens,
468
466
  chunkTokens,
467
+ requestedMaxOutputTokens: OBSERVER_AGENT_LOOP_MAX_TOKENS,
468
+ effectiveMaxOutputTokens: boundedMaxTokens(resolved.model as any, OBSERVER_AGENT_LOOP_MAX_TOKENS),
469
+ advertisedModelMaxTokens: (resolved.model as { maxTokens?: number }).maxTokens,
469
470
  coversUpToId,
470
471
  sourceEntryIds,
471
472
  sourceEntryCount: sourceEntryIds.length,
@@ -473,6 +474,20 @@ async function runObserverStage(
473
474
  priorObservations: priorObservations.length,
474
475
  });
475
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;
476
491
  let observations;
477
492
  let failedMessage: string | undefined;
478
493
  const observerWatchdog = createWorkerStallWatchdog("observer");
@@ -489,6 +504,17 @@ async function runObserverStage(
489
504
  thinkingLevel: runtime.config.model?.thinking ?? "low",
490
505
  recordUsage: (usage) => runtime.recordAgentUsage(usage),
491
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
+ },
492
518
  signal: observerWatchdog.signal,
493
519
  }));
494
520
  } catch (error) {
@@ -505,8 +531,19 @@ async function runObserverStage(
505
531
  chunkTokens,
506
532
  });
507
533
  observations = undefined;
534
+ if (runtime.lastObserverRun?.startedAt === observerStartedAt) {
535
+ runtime.lastObserverRun = { ...runtime.lastObserverRun, status: "failed", error: failedMessage };
536
+ }
508
537
  } finally {
538
+ acceptsObserverMessages = false;
509
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
+ }
510
547
  }
511
548
  if (options.contextGeneration !== undefined && options.contextGeneration !== runtime.getContextGeneration()) {
512
549
  debugLog("observer.stale", { reason: "session_or_branch_changed" });
@@ -527,6 +564,15 @@ async function runObserverStage(
527
564
  });
528
565
  }
529
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
+ }
530
576
  const data = buildObservationsRecordedData(accepted, effectiveCoversUpToId);
531
577
  if (!data) return "continue";
532
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;
@@ -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,