@matthewfl/pi-contemplator 0.1.0 → 0.1.1

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.0",
3
+ "version": "0.1.1",
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",
@@ -1,4 +1,4 @@
1
- import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
1
+ import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentMessage, type AgentTool } from "@earendil-works/pi-agent-core";
2
2
  import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
3
3
  import { Type } from "@earendil-works/pi-ai";
4
4
  import { streamSimple } from "@earendil-works/pi-ai/compat";
@@ -71,6 +71,16 @@ const RecordObservationsSchema = Type.Object({
71
71
 
72
72
  type RecordObservationsArgs = Static<typeof RecordObservationsSchema>;
73
73
 
74
+ /** A terminal provider/agent-loop failure that must not advance observation coverage. */
75
+ export class ObserverStreamError extends Error {
76
+ readonly stopReason: string;
77
+ constructor(stopReason: string, errorMessage?: string) {
78
+ super(`observer stream ended with stopReason "${stopReason}"${errorMessage ? `: ${errorMessage}` : ""}`);
79
+ this.name = "ObserverStreamError";
80
+ this.stopReason = stopReason;
81
+ }
82
+ }
83
+
74
84
  function joinOrEmpty(items: string[]): string {
75
85
  return items.length ? items.join("\n") : "(none yet)";
76
86
  }
@@ -98,14 +108,15 @@ export async function runObserver(args: RunObserverArgs): Promise<Observation[]
98
108
  if (!conversation) return undefined;
99
109
 
100
110
  const accumulated = new Map<string, Observation>();
111
+ let rejectedTotal = 0;
112
+ let doneCalled = false;
101
113
 
102
114
  const recordObservations: AgentTool<typeof RecordObservationsSchema> = {
103
115
  name: "record_observations",
104
116
  label: "Record observations",
105
117
  description:
106
118
  "Record a batch of new observations distilled from the conversation chunk. " +
107
- "Call this multiple times as you work through the chunk. Stop calling when coverage is complete, " +
108
- "then emit a short plain-text confirmation to end the run.",
119
+ "Call this multiple times as you work through the chunk, then call done alone when coverage is complete.",
109
120
  parameters: RecordObservationsSchema,
110
121
  execute: async (_id, params: RecordObservationsArgs) => {
111
122
  let added = 0;
@@ -134,6 +145,7 @@ export async function runObserver(args: RunObserverArgs): Promise<Observation[]
134
145
  });
135
146
  added++;
136
147
  }
148
+ rejectedTotal += rejected;
137
149
  const rejectedPart = rejected > 0
138
150
  ? ` ${rejected} observation${rejected === 1 ? "" : "s"} rejected for missing or invalid sourceEntryIds.`
139
151
  : "";
@@ -142,11 +154,22 @@ export async function runObserver(args: RunObserverArgs): Promise<Observation[]
142
154
  (duplicates > 0 ? `(${duplicates} duplicate${duplicates === 1 ? "" : "s"} skipped).` : ".") +
143
155
  rejectedPart +
144
156
  ` Total so far this run: ${accumulated.size}. ` +
145
- `Continue if the chunk still has uncovered content; otherwise stop calling the tool and emit a short plain-text confirmation.`;
157
+ `Continue if the chunk still has uncovered content; otherwise call done alone.`;
146
158
  return { content: [{ type: "text", text: ack }], details: { added, duplicates, rejected, total: accumulated.size } };
147
159
  },
148
160
  };
149
161
 
162
+ const doneTool: AgentTool<any> = {
163
+ name: "done",
164
+ label: "Done",
165
+ description: "Confirm that the entire provided conversation chunk has been inspected and all useful new observations have been recorded. Call alone, including when there is nothing new to record.",
166
+ parameters: Type.Object({}),
167
+ execute: async () => {
168
+ doneCalled = true;
169
+ return { content: [{ type: "text", text: "Observer coverage confirmed." }], details: {}, terminate: true };
170
+ },
171
+ };
172
+
150
173
  const now = nowTimestamp();
151
174
  const userText = `Current local time: ${now}
152
175
 
@@ -156,60 +179,86 @@ ${joinOrEmpty(priorSummaries)}
156
179
  CURRENT OBSERVATIONS:
157
180
  ${joinOrEmpty(priorObservations)}
158
181
 
159
- Compress the following new conversation chunk into observations by calling record_observations one or more times. Do not restate facts already present in current summaries or current observations. Prefer inline conversation timestamps when assigning times; fall back to the current local time above only if no message timestamp applies. Stop calling the tool and reply with a short plain-text confirmation once the chunk is fully covered.
182
+ Compress the following new conversation chunk into observations by calling record_observations one or more times. Do not restate facts already present in current summaries or current observations. Prefer inline conversation timestamps when assigning times; fall back to the current local time above only if no message timestamp applies. When the chunk is fully covered, call done alone. If the chunk contains no useful new information, call done without calling record_observations.
160
183
 
161
184
  NEW CONVERSATION CHUNK:
162
185
  ${conversation}`;
163
186
 
164
- const prompts: Message[] = [
165
- {
166
- role: "user",
167
- content: [{ type: "text", text: userText }],
168
- timestamp: Date.now(),
169
- },
170
- ];
171
-
172
- const context: AgentContext = {
173
- systemPrompt: OBSERVER_SYSTEM,
174
- messages: [],
175
- tools: [recordObservations as AgentTool<any>],
187
+ const initialPrompt: Message = {
188
+ role: "user",
189
+ content: [{ type: "text", text: userText }],
190
+ timestamp: Date.now(),
176
191
  };
177
192
 
178
193
  const reasoning = (model as { reasoning?: unknown }).reasoning;
179
194
  const thinkingLevel = args.thinkingLevel ?? "low";
180
195
  const effectiveMaxTurns = args.maxTurns && args.maxTurns > 0 ? args.maxTurns : undefined;
181
196
  let turnCount = 0;
182
- const config: AgentLoopConfig = {
197
+ const baseConfig: AgentLoopConfig = {
183
198
  model,
184
199
  apiKey,
185
200
  headers,
186
201
  maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
187
202
  convertToLlm: (msgs) => msgs as Message[],
188
203
  toolExecution: "sequential",
204
+ shouldStopAfterTurn: () => {
205
+ turnCount++;
206
+ return doneCalled || (effectiveMaxTurns !== undefined && turnCount >= effectiveMaxTurns);
207
+ },
189
208
  ...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
190
- ...(effectiveMaxTurns !== undefined
191
- ? {
192
- shouldStopAfterTurn: () => {
193
- turnCount++;
194
- return turnCount >= effectiveMaxTurns;
195
- },
196
- }
197
- : {}),
198
209
  };
199
210
 
200
211
  const loop = args.agentLoop ?? agentLoop;
201
- const stream = loop(prompts, context, config, signal, streamSimple);
202
- for await (const event of stream) {
203
- // Drain events; the tool's execute already collects records.
204
- logAgentStreamError("observer", event);
205
- }
206
- const result = await stream.result();
207
- if (args.recordUsage) {
212
+ const history: AgentMessage[] = [];
213
+ let terminalFailure: { stopReason: string; errorMessage?: string } | undefined;
214
+ const runInvocation = async (prompt: Message): Promise<void> => {
215
+ const context: AgentContext = {
216
+ systemPrompt: OBSERVER_SYSTEM,
217
+ messages: history.slice(),
218
+ tools: [recordObservations as AgentTool<any>, doneTool],
219
+ };
220
+ const stream = loop([prompt], context, baseConfig, signal, streamSimple);
221
+ for await (const event of stream) {
222
+ logAgentStreamError("observer", event);
223
+ const message = (event as { message?: { role?: string; stopReason?: string; errorMessage?: string } }).message;
224
+ if (message?.role === "assistant" && ["error", "aborted", "length"].includes(message.stopReason ?? "")) {
225
+ terminalFailure = { stopReason: message.stopReason!, errorMessage: message.errorMessage };
226
+ }
227
+ }
228
+ const result = await stream.result();
229
+ if (!Array.isArray(result)) return;
230
+ history.push(...result);
208
231
  for (const message of result) {
209
- if (message.role === "assistant" && message.usage) args.recordUsage(message.usage);
232
+ if (message.role === "assistant" && ["error", "aborted", "length"].includes(message.stopReason ?? "")) {
233
+ terminalFailure = { stopReason: message.stopReason, errorMessage: message.errorMessage };
234
+ }
235
+ if (args.recordUsage && message.role === "assistant" && message.usage) args.recordUsage(message.usage);
210
236
  }
237
+ };
238
+
239
+ await runInvocation(initialPrompt);
240
+ if (accumulated.size === 0 && !doneCalled && !terminalFailure && rejectedTotal === 0) {
241
+ const reminder: Message = {
242
+ role: "user",
243
+ content: [{ type: "text", text: `You stopped without confirming coverage. Observations recorded so far: ${accumulated.size}. If the chunk is fully covered, call done now. Otherwise call record_observations for anything still missing, then call done.` }],
244
+ timestamp: Date.now(),
245
+ };
246
+ await runInvocation(reminder);
211
247
  }
212
248
 
249
+ // Accepted observations remain useful even if the model neglected the final
250
+ // confirmation. Zero-observation coverage is advanced only by an explicit
251
+ // done call; failures, truncation, malformed records, and repeated prose do
252
+ // not silently discard the source chunk.
253
+ if (accumulated.size === 0 && terminalFailure) {
254
+ throw new ObserverStreamError(terminalFailure.stopReason, terminalFailure.errorMessage);
255
+ }
256
+ if (accumulated.size === 0 && rejectedTotal > 0) {
257
+ throw new ObserverStreamError("invalid_observations", `${rejectedTotal} proposed observation${rejectedTotal === 1 ? " was" : "s were"} rejected`);
258
+ }
259
+ if (accumulated.size === 0 && !doneCalled) {
260
+ throw new ObserverStreamError("incomplete", "observer stopped twice without recording observations or calling done");
261
+ }
213
262
  if (accumulated.size === 0) return undefined;
214
263
  return Array.from(accumulated.values());
215
264
  }
@@ -15,7 +15,7 @@ How you work:
15
15
  2. Read the conversation chunk and identify what new information it contains.
16
16
  3. Call record_observations with a batch covering part (or all) of the chunk.
17
17
  4. Read the progress receipt. If content remains uncovered, call again. You may call the tool many times.
18
- 5. When the chunk is fully covered, STOP calling the tool and reply with a brief plain-text confirmation (one short sentence). That ends the run.
18
+ 5. When the chunk is fully covered, call done alone. If there is no useful new information, call done without calling record_observations. Prose does not confirm coverage.
19
19
 
20
20
  What to emit:
21
21
  - Produce NEW observations for the new chunk only. Do not restate facts already present in summaries or current observations unless something has materially changed.
@@ -25,7 +25,7 @@ What to emit:
25
25
  - For every observation, choose retention independently from relevance. Recording the observation correctly comes first; never skip useful evidence because retention is uncertain.
26
26
  - Observations with missing, empty, or invalid sourceEntryIds will be rejected and not recorded, so do not call record_observations until you can cite valid source ids.
27
27
  - Group repeated similar tool calls into a single observation rather than one per call.
28
- - Skip routine, low-information events. It is fine to emit zero observations if the chunk carries no new information — in that case, simply do not call the tool and end with a plain-text confirmation.
28
+ - Skip routine, low-information events. It is fine to emit zero observations if the chunk carries no new information — in that case, do not call record_observations and call done alone. Ignoring a chunk or replying in prose does not mark it covered.
29
29
 
30
30
  Observation content rules:
31
31
 
@@ -125,4 +125,4 @@ A critical exact blocker can be contextual; a medium stable preference can be du
125
125
 
126
126
  Timestamp format: "YYYY-MM-DD HH:MM" (local time, 24-hour, to the minute). This goes in the timestamp field, not the content.
127
127
 
128
- Remember: these observations are the assistant's ONLY memory of this chunk once the raw messages fall out of context. Make them count.`;
128
+ Remember: these observations are the assistant's ONLY memory of this chunk once the raw messages fall out of context. Make them count. Always finish by calling done alone.`;
@@ -1,13 +1,14 @@
1
1
  import { DynamicBorder, getSelectListTheme } from "@earendil-works/pi-coding-agent";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { Container, getKeybindings, Input, SelectList, Spacer, Text, fuzzyFilter, type Focusable, type SelectItem } from "@earendil-works/pi-tui";
4
- import type { ConfiguredModel } from "../config.js";
4
+ import { OBSERVER_CHUNK_CONTEXT_RATIO, resolveObserverChunkMaxTokens, type ConfiguredModel } from "../config.js";
5
5
  import { OM_SETTINGS, type Runtime, type SessionSettings } from "../runtime.js";
6
6
 
7
7
  type ModelRegistryLike = {
8
8
  refresh?(): Promise<void>;
9
9
  getAvailable(): Array<{ provider: string; id: string }>;
10
10
  getAll(): Array<{ provider: string; id: string }>;
11
+ find?(provider: string, id: string): { contextWindow?: number } | undefined;
11
12
  };
12
13
  type NumberSetting = "observeAfterTokens" | "compactAfterTokens" | "observerChunkMaxTokens" | "newMemoryPoolMaxTokens" | "oldMemoryPoolTargetTokens" | "agentMaxTurns" | "contemplatorMinNewObservations" | "contemplatorMinNewSummaries" | "contemplatorMinTurns" | "summarizerRetriggerTokens" | "summarizerSamplingThresholdTokens";
13
14
  type BooleanSetting = "contemplatorEnabled" | "showContemplatorMessages" | "reviewerEnabled" | "summarizerEnabled" | "compactionObserverEnabled" | "showWorkerNotifications" | "passive" | "debugLog";
@@ -44,6 +45,26 @@ function extensionEnabledLabel(runtime: Runtime): string {
44
45
  return hasOverride(runtime.getSessionSettings(), "passive") ? String(enabled) : `${enabled} (default)`;
45
46
  }
46
47
 
48
+ export function observerInputCapLabel(runtime: Runtime, contextWindow: number | undefined): string {
49
+ const explicit = runtime.config.observerChunkMaxTokens;
50
+ if (explicit !== undefined) {
51
+ return `${explicit.toLocaleString()} tokens${hasOverride(runtime.getSessionSettings(), "observerChunkMaxTokens") ? "" : " (default)"}`;
52
+ }
53
+ const cap = resolveObserverChunkMaxTokens(runtime.config, contextWindow);
54
+ if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) {
55
+ const percent = OBSERVER_CHUNK_CONTEXT_RATIO * 100;
56
+ return `${percent}% of ${contextWindow.toLocaleString()} = ${cap.toLocaleString()} tokens (derived default)`;
57
+ }
58
+ return `${cap.toLocaleString()} tokens (fallback default; model context unavailable)`;
59
+ }
60
+
61
+ function observerContextWindow(runtime: Runtime, ctx: ExtensionContext): number | undefined {
62
+ const configured = runtime.config.model;
63
+ const registry = ctx.modelRegistry as unknown as ModelRegistryLike;
64
+ const model = configured ? registry.find?.(configured.provider, configured.id) ?? ctx.model : ctx.model;
65
+ return (model as { contextWindow?: number } | undefined)?.contextWindow;
66
+ }
67
+
47
68
  interface ModelOption extends SelectItem {
48
69
  configuredModel: ConfiguredModel | null;
49
70
  }
@@ -207,7 +228,7 @@ export function registerSettingsCommand(pi: ExtensionAPI, runtime: Runtime): voi
207
228
  `Observe source during compaction: ${scalarLabel(runtime, "compactionObserverEnabled")}`,
208
229
  `Observer and summarizer model: ${hasOverride(settings, "model") ? modelLabel(runtime.config.model) : `${modelLabel(runtime.getDefaultConfig().model)} (default)`}`,
209
230
  `Observer source backlog trigger (tokens): ${scalarLabel(runtime, "observeAfterTokens")}`,
210
- `Observer input cap (tokens): ${scalarLabel(runtime, "observerChunkMaxTokens")}`,
231
+ `Observer input cap: ${observerInputCapLabel(runtime, observerContextWindow(runtime, ctx))}`,
211
232
  `Observer and summarizer max rounds: ${scalarLabel(runtime, "agentMaxTurns")}`,
212
233
  `Automatic compaction source backlog trigger (tokens): ${scalarLabel(runtime, "compactAfterTokens")}`,
213
234
  `Automatic compaction threshold mode: ${hasOverride(settings, "compactAfterTokensMode") ? runtime.config.compactAfterTokensMode : `${runtime.getDefaultConfig().compactAfterTokensMode} (default)`}`,
@@ -245,7 +266,7 @@ export function registerSettingsCommand(pi: ExtensionAPI, runtime: Runtime): voi
245
266
  } else {
246
267
  const numberChoice: Array<[string, NumberSetting, string]> = [
247
268
  ["Observer source backlog trigger (tokens):", "observeAfterTokens", "Observer source backlog trigger (tokens)"],
248
- ["Observer input cap (tokens):", "observerChunkMaxTokens", "Observer input cap (tokens)"],
269
+ ["Observer input cap:", "observerChunkMaxTokens", "Observer input cap (tokens)"],
249
270
  ["Observer and summarizer max rounds:", "agentMaxTurns", "Observer and summarizer max rounds"],
250
271
  ["Automatic compaction source backlog trigger (tokens):", "compactAfterTokens", "Automatic compaction source backlog trigger (tokens)"],
251
272
  ["New memory pool protection budget (tokens):", "newMemoryPoolMaxTokens", "New memory pool protection budget (tokens)"],
package/src/config.ts CHANGED
@@ -125,11 +125,11 @@ export const OBSERVER_CHUNK_MIN_TOKENS = 256;
125
125
  /**
126
126
  * Fraction of the memory model's context window used for the derived observer
127
127
  * chunk cap. Chunk sizes are estimated at ~4 chars/token, which can undercount
128
- * real tokens by up to ~4x on non-ASCII content, so 0.2 keeps even the worst
129
- * case at ~80% of the window with room left for the system prompt, prior
130
- * memory, and the response.
128
+ * real tokens substantially on non-ASCII content. The estimator remains
129
+ * approximate; the remaining 75% of the advertised window accommodates injected memory, the
130
+ * system prompt, and model output.
131
131
  */
132
- export const OBSERVER_CHUNK_CONTEXT_RATIO = 0.2;
132
+ export const OBSERVER_CHUNK_CONTEXT_RATIO = 0.25;
133
133
 
134
134
  /**
135
135
  * Resolve the maximum estimated tokens the observer serializes into one chunk.
@@ -143,7 +143,7 @@ export const OBSERVER_CHUNK_CONTEXT_RATIO = 0.2;
143
143
  * after repeated observer failures, or when the extension is enabled mid-way
144
144
  * into a long session) makes every observer call fail, so coverage never
145
145
  * advances and the session can never recover. With the cap, oversized backlogs
146
- * are drained oldest-first across successive runs.
146
+ * are drained oldest-first across successive bounded passes.
147
147
  */
148
148
  export function resolveObserverChunkMaxTokens(config: Config, contextWindow: number | undefined): number {
149
149
  if (config.observerChunkMaxTokens !== undefined && config.observerChunkMaxTokens > 0) {
@@ -227,12 +227,27 @@ export async function runConsolidationPipeline(
227
227
  runtime.consolidationPhase = "observer";
228
228
  runtime.lastObserverStartedAt = Date.now();
229
229
  try {
230
- const observerOutcome = await runObserverStage(pi, runtime, ctx, resolveModel, {
231
- force: options.forceObserver === true,
232
- entries: options.observerEntries,
233
- contextGeneration,
234
- });
235
- if (observerOutcome === "abort") return;
230
+ // A large backlog is drained in bounded, oldest-first chunks. The normal
231
+ // trigger threshold controls when the batch stops; a static compaction
232
+ // snapshot is intentionally processed only once. Coverage must advance on
233
+ // every iteration, otherwise stop rather than spin on a failed chunk.
234
+ while (true) {
235
+ const beforeEntries = ctx.sessionManager.getBranch() as Entry[];
236
+ const beforeCoverage = latestCoverageIndex(beforeEntries, OM_OBSERVATIONS_RECORDED);
237
+ const observerOutcome = await runObserverStage(pi, runtime, ctx, resolveModel, {
238
+ force: options.forceObserver === true,
239
+ entries: options.observerEntries,
240
+ contextGeneration,
241
+ });
242
+ if (observerOutcome === "abort") return;
243
+ if (options.observerEntries) break;
244
+
245
+ const afterEntries = ctx.sessionManager.getBranch() as Entry[];
246
+ const afterCoverage = latestCoverageIndex(afterEntries, OM_OBSERVATIONS_RECORDED);
247
+ const remainingTokens = rawTokensSinceObservationCoverage(afterEntries);
248
+ if (afterCoverage <= beforeCoverage || remainingTokens < runtime.config.observeAfterTokens) break;
249
+ debugLog("observer.backlog_continue", { remainingTokens, afterCoverage });
250
+ }
236
251
  } catch (error) {
237
252
  debugLog("observer.error", { errorMessage: runtime.recordConsolidationStageError(ctx, "observer", error) });
238
253
  return;
@@ -465,15 +480,6 @@ async function runObserverStage(
465
480
  debugLog("observer.stale", { reason: "session_or_branch_changed" });
466
481
  return "abort";
467
482
  }
468
- if (!observations || observations.length === 0) {
469
- debugLog("observer.empty", { coversUpToId });
470
- if (ctx.hasUI) ctx.ui?.notify(
471
- "pi-contemplator: observer returned no observations",
472
- "warning",
473
- );
474
- return "continue";
475
- }
476
-
477
483
  const currentEntries = ctx.sessionManager.getBranch() as Entry[];
478
484
  let effectiveCoversUpToId = coversUpToId;
479
485
  if (!currentEntries.some((entry) => entry.id === coversUpToId)) {
@@ -488,17 +494,24 @@ async function runObserverStage(
488
494
  compactionId: compaction?.id,
489
495
  });
490
496
  }
491
- const data = buildObservationsRecordedData(observations, effectiveCoversUpToId);
497
+ const accepted = observations ?? [];
498
+ const data = buildObservationsRecordedData(accepted, effectiveCoversUpToId);
492
499
  if (!data) return "continue";
493
- debugLog("observer.records", {
494
- count: observations.length,
495
- observationTokens: observations.reduce((sum, observation) => sum + observation.tokenCount, 0),
500
+ debugLog(accepted.length > 0 ? "observer.records" : "observer.coverage_only", {
501
+ count: accepted.length,
502
+ observationTokens: accepted.reduce((sum, observation) => sum + observation.tokenCount, 0),
496
503
  coversUpToId: effectiveCoversUpToId,
497
504
  });
505
+ // A clean zero-observation verdict is still successful coverage. Persist an
506
+ // empty batch so the next bounded pass starts after this chunk instead of
507
+ // retrying the same low-information source forever. Failures throw above and
508
+ // therefore never reach this coverage commit.
498
509
  appendEntry(pi, OM_OBSERVATIONS_RECORDED, data);
499
- debugLog("observer.appended", { count: observations.length, coversUpToId: effectiveCoversUpToId });
510
+ debugLog("observer.appended", { count: accepted.length, coversUpToId: effectiveCoversUpToId });
500
511
  if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
501
- `pi-contemplator: ${observations.length} observation${observations.length === 1 ? "" : "s"} recorded`,
512
+ accepted.length > 0
513
+ ? `pi-contemplator: ${accepted.length} observation${accepted.length === 1 ? "" : "s"} recorded`
514
+ : "pi-contemplator: observer found no new information; processed chunk marked covered",
502
515
  "info",
503
516
  );
504
517
  return "continue";
@@ -74,7 +74,7 @@ function isNonEmptyArray(value: unknown): value is unknown[] {
74
74
  function isValidCoverageEntry(entry: Entry, customType: MemoryCoverageCustomType): entry is Entry & { data: { coversUpToId: string } } {
75
75
  if (entry.type !== "custom" || entry.customType !== customType) return false;
76
76
  if (!isObject(entry.data) || typeof entry.data.coversUpToId !== "string") return false;
77
- if (customType === OM_OBSERVATIONS_RECORDED) return isNonEmptyArray(entry.data.observations);
77
+ if (customType === OM_OBSERVATIONS_RECORDED) return Array.isArray(entry.data.observations);
78
78
  return customType === OM_SUMMARIZER_COMMIT && isNonEmptyArray(entry.data.summaries);
79
79
  }
80
80
 
@@ -235,7 +235,6 @@ export function isObservationsRecordedData(value: unknown): value is Observation
235
235
  if (!isPlainRecord(value)) return false;
236
236
  return (
237
237
  Array.isArray(value.observations) &&
238
- value.observations.length > 0 &&
239
238
  value.observations.every(isObservation) &&
240
239
  isNonEmptyString(value.coversUpToId)
241
240
  );
@@ -345,7 +344,7 @@ export function buildObservationsRecordedData(
345
344
  observations: Observation[],
346
345
  coversUpToId: string,
347
346
  ): ObservationsRecordedEntryData | undefined {
348
- if (observations.length === 0 || !isNonEmptyString(coversUpToId)) return undefined;
347
+ if (!isNonEmptyString(coversUpToId)) return undefined;
349
348
  const candidate = { observations, coversUpToId };
350
349
  return isObservationsRecordedData(candidate) ? candidate : undefined;
351
350
  }