pi-observational-memory 3.0.3 → 3.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/README.md CHANGED
@@ -173,6 +173,8 @@ This extension is especially useful when the session contains decisions that sho
173
173
 
174
174
  ## Install
175
175
 
176
+ Requires Pi 0.81.0 or newer. Proactive compaction uses the `agent_settled` lifecycle event introduced in that release.
177
+
176
178
  ```bash
177
179
  pi install npm:pi-observational-memory
178
180
  ```
@@ -218,6 +220,7 @@ A typical config:
218
220
  "id": "google/gemma-4-31b-it",
219
221
  "thinking": "low"
220
222
  },
223
+ "showWorkerNotifications": true,
221
224
  "passive": false,
222
225
  "debugLog": false
223
226
  }
@@ -226,12 +229,14 @@ A typical config:
226
229
 
227
230
  Most users can start with the defaults and tune only if they have a specific reason.
228
231
 
232
+ If your memory model is a local llama.cpp server, size `agentMaxTokens` so that a worst-case request (observer chunk + prior memory + system prompt + the full response budget) fits inside the server's context: slot KV is shared between the main session's retained cache and concurrent sub-agent requests, so an over-budget sub-agent request fails with `500 "Context size has been exceeded."` and the affected memory run aborts. For example, on a 64K-slot server, pairing `"agentMaxTokens": 8192` with a low `observerChunkMaxTokens` keeps sub-agent requests well inside the window.
233
+
229
234
  ### Scaling compaction to the model's context window
230
235
 
231
236
  By default `compactAfterTokensMode` is `"calibrated"`, so the proactive
232
- compaction trigger fires at the fixed `compactAfterTokens` value (81,000 by
233
- default). That is backwards-compatible and works well for typical ~128K–200K
234
- context models.
237
+ compaction trigger uses the fixed `compactAfterTokens` estimated source-entry
238
+ threshold (81,000 by default). This preserves the pre-PR #40 compaction metric
239
+ for typical ~128K–200K context models.
235
240
 
236
241
  On a large-context model (e.g. 1M tokens) the calibrated default preempts
237
242
  compaction at ~81K, wasting most of the window. Switch to `"ratio"` mode to let
@@ -249,8 +254,11 @@ the trigger scale with the active model's `contextWindow`:
249
254
 
250
255
  In ratio mode the effective threshold is
251
256
  `floor(model.contextWindow * compactAfterTokensRatio)` (clamped to a minimum of
252
- 1). With the example above, a 1,000,000-token window compacts at ~500,000 raw
253
- tokens; a 200,000-token window compacts at ~100,000.
257
+ 1). With the example above, a 1,000,000-token window compacts after about
258
+ 500,000 estimated source-entry tokens after the latest compaction boundary; a
259
+ 200,000-token window uses about 100,000. The threshold counts source entries,
260
+ not Pi's system prompt, tool schemas, or provider accounting. Pi's native
261
+ window-pressure compaction remains independent.
254
262
 
255
263
  `compactAfterTokensRatio` is user-tunable precisely because **context window ≠
256
264
  attention**. Some models advertise a large window but degrade at long range; set
@@ -268,14 +276,17 @@ on the `Next compaction` line regardless of mode.
268
276
  | Setting | Default | Meaning |
269
277
  | --------------------------- | ------------- | ------------------------------------------------------------------------------------------------- |
270
278
  | `observeAfterTokens` | `10000` | Raw/source token threshold for observation runs. |
279
+ | `observerChunkMaxTokens` | derived | Max estimated tokens serialized into one observer chunk (minimum `256`). Unset: `floor(contextWindow * 0.2)` of the resolved memory model, or `60000` when the window is unknown. Larger backlogs drain oldest-first; a single over-budget source is sent as a marked head/tail excerpt while the original source remains in the session ledger. |
271
280
  | `reflectAfterTokens` | `20000` | Raw/source token threshold for reflection runs; successful reflection creates dropper opportunities. |
272
- | `compactAfterTokens` | `81000` | Raw/source token threshold for proactive auto-compaction (used directly in `"calibrated"` mode, and as the fallback in `"ratio"` mode). |
273
- | `compactAfterTokensMode` | `"calibrated"`| `"calibrated"` uses `compactAfterTokens` directly (default, backwards-compatible). `"ratio"` scales the threshold by the active model's `contextWindow`. |
281
+ | `compactAfterTokens` | `81000` | Estimated source-entry threshold for proactive auto-compaction, counted after the latest compaction boundary. |
282
+ | `compactAfterTokensMode` | `"calibrated"`| `"calibrated"` uses `compactAfterTokens` directly. `"ratio"` scales the source-entry threshold by the active model's `contextWindow`. |
274
283
  | `compactAfterTokensRatio` | `0.68` | In `"ratio"` mode, the threshold is `floor(contextWindow * ratio)`. Tunable because large windows do not always mean strong long-range attention. Must be in `(0, 1)`. |
275
284
  | `observationsPoolMaxTokens` | `20000` | Observation-token budget used for compaction full-fold pressure. |
276
285
  | `observationsPoolTargetTokens` | half of max | Active observation target used by post-reflection dropper maintenance. |
277
286
  | `agentMaxTurns` | `16` | Shared turn cap for background memory-agent loops. |
287
+ | `agentMaxTokens` | `32000` | Maximum output tokens requested for memory-agent loops (observer/reflector/dropper), clamped to the model's own `maxTokens` when available. Lower it for local servers with a modest context window, e.g. `8192`. |
278
288
  | `model` | session model | Optional memory-worker model override: `{ provider, id, thinking }`. |
289
+ | `showWorkerNotifications` | `true` | Shows routine observer, reflector, and dropper progress notifications. Warnings and errors are unaffected. |
279
290
  | `passive` | `false` | Disables proactive background observation, reflection, maintenance, and auto-compaction triggers. |
280
291
  | `debugLog` | `false` | Writes opt-in per-session extension debug events to Pi's agent directory. |
281
292
 
@@ -287,8 +298,11 @@ Valid `model.thinking` values are:
287
298
  * `medium`
288
299
  * `high`
289
300
  * `xhigh`
301
+ * `max`
290
302
 
291
- If no `model` is configured, memory workers use the session model.
303
+ If no `model` is configured, memory workers use the session model, including custom `pi.registerProvider` APIs such as `cursor-sdk`. You do not need a second built-in provider (OpenAI, OpenRouter, …) for observational memory to run. Set `model` only when you want cheaper or faster workers than the coding agent.
304
+
305
+ Set `showWorkerNotifications` to `false` to hide routine worker start and completion messages (including deliberate-empty observer info messages). Model fallback/unavailability, worker failures (including observer stream errors), compaction notifications, and explicit `/om:*` command output remain visible.
292
306
 
293
307
  `observationsPoolMaxTokens` and `observationsPoolTargetTokens` intentionally describe different pools. Max tokens control when compaction performs a full fold over visible memory. Target tokens control the folded active observation pool that the dropper maintains after successful reflection. If the target is omitted, it defaults to half of max.
294
308
 
@@ -320,14 +334,14 @@ flowchart TD
320
334
  Turn[turn_end]
321
335
  Observe[Capture observations]
322
336
  Reflect[Distill reflections]
323
- AgentEnd[agent_end]
337
+ AgentSettled[agent_settled]
324
338
  Trigger[auto-compaction trigger]
325
339
  Compact[session_before_compact]
326
340
  Summary[visible memory for Pi]
327
341
 
328
342
  Turn -->|observation due| Observe
329
343
  Turn -->|reflection due| Reflect
330
- AgentEnd -->|compactAfterTokens and idle| Trigger --> Compact --> Summary
344
+ AgentSettled -->|compactAfterTokens and idle| Trigger --> Compact --> Summary
331
345
  ```
332
346
 
333
347
  The high-level lifecycle:
@@ -340,6 +354,12 @@ The high-level lifecycle:
340
354
 
341
355
  The important part: compaction does not need to rethink the whole session from scratch.
342
356
 
357
+ The proactive compaction threshold counts estimated source-entry tokens after
358
+ the latest compaction boundary. It includes source entries retained by
359
+ `firstKeptEntryId` and newer source entries, while memory ledger entries and
360
+ compaction metadata contribute zero. `/om:status` uses the same metric. Pi's
361
+ own window-pressure compaction remains independent.
362
+
343
363
  ---
344
364
 
345
365
  ## Current V3 behavior
@@ -348,7 +368,7 @@ Current behavior:
348
368
 
349
369
  * **Observation-centered memory.** The extension records useful session observations while you work.
350
370
  * **Durable reflections.** The extension distills stable facts that help the agent stay oriented over time.
351
- * **Fast compaction.** `session_before_compact` does not call a model or wait for background workers. It renders the current prepared memory state.
371
+ * **Fast compaction.** When prepared V3 memory exists, `session_before_compact` renders it without calling a model or waiting for background workers. An empty V3 projection delegates to Pi's native summarizer instead of replacing prior context with an empty summary.
352
372
  * **Background memory work.** Observation and reflection work run from `turn_end` when their token clocks are due; dropper work runs only after successful reflection and prunes the folded active observation ledger toward `observationsPoolTargetTokens`.
353
373
  * **Source-backed recall.** Observations and reflections can be traced back through the `recall` tool.
354
374
  * **Visible/full views.** `/om:view` shows visible memory and `/om:view full` shows the full current memory state. Use `/om:status` for visible-vs-full drift and for the separate visible observation pool vs active observation pool.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-observational-memory",
3
- "version": "3.0.3",
3
+ "version": "3.1.1",
4
4
  "description": "Observational memory extension for pi — cache-friendly tiered compaction with observations and reflections.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -40,10 +40,10 @@
40
40
  "@earendil-works/pi-tui": "*"
41
41
  },
42
42
  "devDependencies": {
43
- "@earendil-works/pi-agent-core": "^0.74.0",
44
- "@earendil-works/pi-ai": "^0.74.0",
45
- "@earendil-works/pi-coding-agent": "^0.74.0",
46
- "@earendil-works/pi-tui": "^0.74.0",
43
+ "@earendil-works/pi-agent-core": "^0.81.0",
44
+ "@earendil-works/pi-ai": "^0.81.0",
45
+ "@earendil-works/pi-coding-agent": "^0.81.0",
46
+ "@earendil-works/pi-tui": "^0.81.0",
47
47
  "@types/node": "^22.0.0",
48
48
  "typebox": "^1.1.38",
49
49
  "typescript": "^5.6.0",
@@ -4,6 +4,8 @@ import { Type } from "@earendil-works/pi-ai";
4
4
  import type { Static } from "typebox";
5
5
  import { debugLog } from "../../debug-log.js";
6
6
  import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
7
+ import { logAgentStreamError } from "../stream-errors.js";
8
+ import { resolveWorkerStreamSimple, type StreamableModelRegistry, type WorkerStreamSimple } from "../worker-stream.js";
7
9
  import { reflectionToSummaryLine, type Observation, type Reflection } from "../../session-ledger/index.js";
8
10
  import { DROPPER_SYSTEM } from "./prompts.js";
9
11
  import {
@@ -37,15 +39,20 @@ export type { CoverageSummaryByRelevance, CoverageTransitionSummaryByRelevance,
37
39
 
38
40
  interface RunDropperArgs {
39
41
  model: Model<any>;
40
- apiKey: string;
42
+ apiKey?: string;
41
43
  headers?: Record<string, string>;
44
+ env?: Record<string, string>;
42
45
  reflections: Reflection[];
43
46
  observations: Observation[];
44
47
  targetTokens: number;
45
48
  signal?: AbortSignal;
46
49
  agentLoop?: typeof agentLoop;
47
50
  maxTurns?: number;
51
+ /** Maximum output tokens for the loop (defaults to {@link AGENT_LOOP_MAX_TOKENS}). */
52
+ maxOutputTokens?: number;
48
53
  thinkingLevel?: ModelThinkingLevel;
54
+ modelRegistry?: StreamableModelRegistry;
55
+ streamSimple?: WorkerStreamSimple;
49
56
  }
50
57
 
51
58
  const RELEVANCE_DROP_RANK: Record<Observation["relevance"], number> = {
@@ -129,7 +136,7 @@ export function selectDropCandidates(
129
136
  }
130
137
 
131
138
  export async function runDropper(args: RunDropperArgs): Promise<string[] | undefined> {
132
- const { model, apiKey, headers, reflections, observations, targetTokens, signal } = args;
139
+ const { model, apiKey, headers, env, reflections, observations, targetTokens, signal } = args;
133
140
  if (observations.length === 0) return undefined;
134
141
 
135
142
  const metrics = observationPoolMetrics(observations, targetTokens);
@@ -241,7 +248,8 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
241
248
  model,
242
249
  apiKey,
243
250
  headers,
244
- maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
251
+ env,
252
+ maxTokens: boundedMaxTokens(model, args.maxOutputTokens ?? AGENT_LOOP_MAX_TOKENS),
245
253
  convertToLlm: (msgs) => msgs as Message[],
246
254
  toolExecution: "sequential",
247
255
  ...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
@@ -249,9 +257,16 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
249
257
  };
250
258
 
251
259
  const loop = args.agentLoop ?? agentLoop;
252
- const stream = loop(prompts, context, config, signal);
253
- for await (const _event of stream) {
260
+ const stream = loop(
261
+ prompts,
262
+ context,
263
+ config,
264
+ signal,
265
+ resolveWorkerStreamSimple(model, args.modelRegistry, args.streamSimple),
266
+ );
267
+ for await (const event of stream) {
254
268
  // Tool execution collects candidate ids.
269
+ logAgentStreamError("dropper", event);
255
270
  }
256
271
  await stream.result();
257
272
  const droppedIds = selectDropCandidates(proposedDropIds, observations, maxDropsAllowed, reflections);
@@ -1,3 +1,4 @@
1
+ import { observationLineTokenCount } from "../../tokens.js";
1
2
  import type { Observation } from "../../session-ledger/index.js";
2
3
 
3
4
  export type ObservationPoolMetrics = {
@@ -12,8 +13,11 @@ export type ObservationPoolMetrics = {
12
13
  ready: boolean;
13
14
  };
14
15
 
15
- export function observationTokenSum(observations: readonly { tokenCount: number }[]): number {
16
- return observations.reduce((sum, observation) => sum + observation.tokenCount, 0);
16
+ export function observationTokenSum(observations: readonly Observation[]): number {
17
+ // Count the full rendered line (id + timestamp + relevance + content), not
18
+ // bare content: the pool budget caps how much observation text is re-rendered
19
+ // into future contexts, and every line carries metadata overhead.
20
+ return observations.reduce((sum, observation) => sum + observationLineTokenCount(observation), 0);
17
21
  }
18
22
 
19
23
  export function observationPoolFullness(observationTokens: number, targetTokens: number): number {
@@ -3,16 +3,19 @@ import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
3
3
  import { Type } from "@earendil-works/pi-ai";
4
4
  import type { Static } from "typebox";
5
5
  import { hashId } from "../../ids.js";
6
+ import { logAgentStreamError } from "../stream-errors.js";
7
+ import { resolveWorkerStreamSimple, type StreamableModelRegistry, type WorkerStreamSimple } from "../worker-stream.js";
6
8
  import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
7
9
  import { OBSERVER_SYSTEM } from "./prompts.js";
8
10
  import { nowTimestamp, truncateRecordContent } from "../../serialize.js";
9
11
  import type { Observation, Relevance } from "../../session-ledger/index.js";
10
- import { estimateStringTokens } from "../../tokens.js";
12
+ import { observationLineTokenCount } from "../../tokens.js";
11
13
 
12
14
  interface RunObserverArgs {
13
15
  model: Model<any>;
14
- apiKey: string;
16
+ apiKey?: string;
15
17
  headers?: Record<string, string>;
18
+ env?: Record<string, string>;
16
19
  priorReflections: string[];
17
20
  priorObservations: string[];
18
21
  chunk: string;
@@ -20,7 +23,11 @@ interface RunObserverArgs {
20
23
  signal?: AbortSignal;
21
24
  agentLoop?: typeof agentLoop;
22
25
  maxTurns?: number;
26
+ /** Maximum output tokens for the loop (defaults to {@link AGENT_LOOP_MAX_TOKENS}). */
27
+ maxOutputTokens?: number;
23
28
  thinkingLevel?: ModelThinkingLevel;
29
+ modelRegistry?: StreamableModelRegistry;
30
+ streamSimple?: WorkerStreamSimple;
24
31
  }
25
32
 
26
33
  const RelevanceSchema = Type.Union([
@@ -60,6 +67,21 @@ const RecordObservationsSchema = Type.Object({
60
67
 
61
68
  type RecordObservationsArgs = Static<typeof RecordObservationsSchema>;
62
69
 
70
+ /**
71
+ * Thrown when the agent loop ends with an API/stream failure (`stopReason`
72
+ * `"error"`/`"aborted"`) without recording anything. agent-core returns such
73
+ * runs normally, so without this the caller cannot tell a hard failure from a
74
+ * deliberate empty result (#32).
75
+ */
76
+ export class ObserverStreamError extends Error {
77
+ readonly stopReason: string;
78
+ constructor(stopReason: string, errorMessage?: string) {
79
+ super(`observer stream ended with stopReason "${stopReason}"${errorMessage ? `: ${errorMessage}` : ""}`);
80
+ this.name = "ObserverStreamError";
81
+ this.stopReason = stopReason;
82
+ }
83
+ }
84
+
63
85
  function joinOrEmpty(items: string[]): string {
64
86
  return items.length ? items.join("\n") : "(none yet)";
65
87
  }
@@ -82,7 +104,7 @@ export function normalizeSourceEntryIds(
82
104
  }
83
105
 
84
106
  export async function runObserver(args: RunObserverArgs): Promise<Observation[] | undefined> {
85
- const { model, apiKey, headers, priorReflections, priorObservations, chunk, allowedSourceEntryIds, signal } = args;
107
+ const { model, apiKey, headers, env, priorReflections, priorObservations, chunk, allowedSourceEntryIds, signal } = args;
86
108
  const conversation = chunk.trim();
87
109
  if (!conversation) return undefined;
88
110
 
@@ -118,7 +140,12 @@ export async function runObserver(args: RunObserverArgs): Promise<Observation[]
118
140
  timestamp: obs.timestamp,
119
141
  relevance: obs.relevance as Relevance,
120
142
  sourceEntryIds,
121
- tokenCount: estimateStringTokens(content),
143
+ tokenCount: observationLineTokenCount({
144
+ id,
145
+ timestamp: obs.timestamp,
146
+ relevance: obs.relevance,
147
+ content,
148
+ }),
122
149
  });
123
150
  added++;
124
151
  }
@@ -171,7 +198,8 @@ ${conversation}`;
171
198
  model,
172
199
  apiKey,
173
200
  headers,
174
- maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
201
+ env,
202
+ maxTokens: boundedMaxTokens(model, args.maxOutputTokens ?? AGENT_LOOP_MAX_TOKENS),
175
203
  convertToLlm: (msgs) => msgs as Message[],
176
204
  toolExecution: "sequential",
177
205
  ...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
@@ -186,12 +214,29 @@ ${conversation}`;
186
214
  };
187
215
 
188
216
  const loop = args.agentLoop ?? agentLoop;
189
- const stream = loop(prompts, context, config, signal);
190
- for await (const _event of stream) {
217
+ const stream = loop(
218
+ prompts,
219
+ context,
220
+ config,
221
+ signal,
222
+ resolveWorkerStreamSimple(model, args.modelRegistry, args.streamSimple),
223
+ );
224
+ let streamError: { stopReason: string; errorMessage?: string } | undefined;
225
+ for await (const event of stream) {
191
226
  // Drain events; the tool's execute already collects records.
227
+ logAgentStreamError("observer", event);
228
+ // Watch for a terminal API/stream failure so it is not conflated with
229
+ // a deliberate empty result.
230
+ const message = (event as { message?: { role?: string; stopReason?: string; errorMessage?: string } }).message;
231
+ if (message?.role === "assistant" && (message.stopReason === "error" || message.stopReason === "aborted")) {
232
+ streamError = { stopReason: message.stopReason, errorMessage: message.errorMessage };
233
+ }
192
234
  }
193
235
  await stream.result();
194
236
 
195
- if (accumulated.size === 0) return undefined;
237
+ if (accumulated.size === 0) {
238
+ if (streamError) throw new ObserverStreamError(streamError.stopReason, streamError.errorMessage);
239
+ return undefined;
240
+ }
196
241
  return Array.from(accumulated.values());
197
242
  }
@@ -4,6 +4,8 @@ import { Type } from "@earendil-works/pi-ai";
4
4
  import type { Static } from "typebox";
5
5
  import { debugLog } from "../../debug-log.js";
6
6
  import { hashId } from "../../ids.js";
7
+ import { logAgentStreamError } from "../stream-errors.js";
8
+ import { resolveWorkerStreamSimple, type StreamableModelRegistry, type WorkerStreamSimple } from "../worker-stream.js";
7
9
  import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
8
10
  import { truncateRecordContent } from "../../serialize.js";
9
11
  import { REFLECTOR_SYSTEM } from "./prompts.js";
@@ -19,14 +21,19 @@ import {
19
21
 
20
22
  interface RunReflectorArgs {
21
23
  model: Model<any>;
22
- apiKey: string;
24
+ apiKey?: string;
23
25
  headers?: Record<string, string>;
26
+ env?: Record<string, string>;
24
27
  reflections: Reflection[];
25
28
  observations: Observation[];
26
29
  signal?: AbortSignal;
27
30
  agentLoop?: typeof agentLoop;
28
31
  maxTurns?: number;
32
+ /** Maximum output tokens for the loop (defaults to {@link AGENT_LOOP_MAX_TOKENS}). */
33
+ maxOutputTokens?: number;
29
34
  thinkingLevel?: ModelThinkingLevel;
35
+ modelRegistry?: StreamableModelRegistry;
36
+ streamSimple?: WorkerStreamSimple;
30
37
  }
31
38
 
32
39
  const RecordReflectionsSchema = Type.Object({
@@ -103,7 +110,7 @@ function normalizeReflectionContent(content: string): string | undefined {
103
110
  }
104
111
 
105
112
  export async function runReflector(args: RunReflectorArgs): Promise<Reflection[] | undefined> {
106
- const { model, apiKey, headers, reflections, observations, signal } = args;
113
+ const { model, apiKey, headers, env, reflections, observations, signal } = args;
107
114
  if (observations.length === 0) return undefined;
108
115
 
109
116
  const coverageById = reflectionCoverageMap(observations, reflections);
@@ -174,7 +181,8 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
174
181
  model,
175
182
  apiKey,
176
183
  headers,
177
- maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
184
+ env,
185
+ maxTokens: boundedMaxTokens(model, args.maxOutputTokens ?? AGENT_LOOP_MAX_TOKENS),
178
186
  convertToLlm: (msgs) => msgs as Message[],
179
187
  toolExecution: "sequential",
180
188
  ...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
@@ -182,9 +190,16 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
182
190
  };
183
191
 
184
192
  const loop = args.agentLoop ?? agentLoop;
185
- const stream = loop(prompts, context, config, signal);
186
- for await (const _event of stream) {
193
+ const stream = loop(
194
+ prompts,
195
+ context,
196
+ config,
197
+ signal,
198
+ resolveWorkerStreamSimple(model, args.modelRegistry, args.streamSimple),
199
+ );
200
+ for await (const event of stream) {
187
201
  // Tool execution collects records.
202
+ logAgentStreamError("reflector", event);
188
203
  }
189
204
  await stream.result();
190
205
  const acceptedReflections = Array.from(accumulated.values());
@@ -0,0 +1,22 @@
1
+ import type { AgentEvent } from "@earendil-works/pi-agent-core";
2
+ import { debugLog } from "../debug-log.js";
3
+
4
+ /**
5
+ * Surface LLM failures from an agent-loop event stream.
6
+ *
7
+ * When the underlying LLM call fails, the loop ends the stream with a final
8
+ * assistant message whose stopReason is "error" (or "aborted") — no exception
9
+ * is thrown. Without this hook the drain loops treat such runs exactly like
10
+ * "the model chose not to call the tool", which hides the real cause
11
+ * (rate limits, oversized prompts, auth failures, ...) from the debug log.
12
+ */
13
+ export function logAgentStreamError(stage: "observer" | "reflector" | "dropper", event: AgentEvent): void {
14
+ if (event.type !== "message_end") return;
15
+ const message = event.message;
16
+ if (message.role !== "assistant") return;
17
+ if (message.stopReason !== "error" && message.stopReason !== "aborted") return;
18
+ debugLog(`${stage}.stream_error`, {
19
+ stopReason: message.stopReason,
20
+ errorMessage: message.errorMessage,
21
+ });
22
+ }
@@ -0,0 +1,65 @@
1
+ import type { AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
2
+ import { streamSimple as compatStreamSimple } from "@earendil-works/pi-ai/compat";
3
+
4
+ export type WorkerStreamSimple = (
5
+ model: Model<any>,
6
+ context: Context,
7
+ options?: SimpleStreamOptions,
8
+ ) => AssistantMessageEventStream;
9
+
10
+ /**
11
+ * Duck-typed subset of Pi's extension ModelRegistry.
12
+ *
13
+ * `streamSimple` is the host-composed path (Pi #8964). Until that lands on the
14
+ * facade, `getRegisteredProviderConfig` still exposes each `registerProvider`
15
+ * `streamSimple` handler, keyed by the extension provider id — match on
16
+ * `config.api === model.api`.
17
+ */
18
+ export type StreamableModelRegistry = {
19
+ streamSimple?: WorkerStreamSimple;
20
+ getRegisteredProviderIds?: () => readonly string[];
21
+ getRegisteredProviderConfig?: (providerId: string) => {
22
+ api?: string;
23
+ streamSimple?: WorkerStreamSimple;
24
+ } | undefined;
25
+ };
26
+
27
+ /**
28
+ * Resolve the stream function background workers must pass to `agentLoop`.
29
+ *
30
+ * Direct `@earendil-works/pi-ai/compat` `streamSimple` only knows built-in API
31
+ * ids. Custom providers (`cursor-sdk`, `cliproxyapi-*`, commandcode, …) live on
32
+ * Pi's composed runtime. Using compat after a successful foreground turn is
33
+ * what crashes Pi with `No API provider registered for api: …` (#30).
34
+ */
35
+ export function resolveWorkerStreamSimple(
36
+ model: Model<any>,
37
+ modelRegistry?: StreamableModelRegistry | null,
38
+ override?: WorkerStreamSimple,
39
+ ): WorkerStreamSimple {
40
+ if (override) return override;
41
+
42
+ const registryStream = modelRegistry?.streamSimple;
43
+ if (typeof registryStream === "function") {
44
+ return (nextModel, context, options) => registryStream(nextModel, context, options);
45
+ }
46
+
47
+ try {
48
+ if (
49
+ typeof modelRegistry?.getRegisteredProviderIds === "function"
50
+ && typeof modelRegistry?.getRegisteredProviderConfig === "function"
51
+ ) {
52
+ for (const providerId of modelRegistry.getRegisteredProviderIds()) {
53
+ const config = modelRegistry.getRegisteredProviderConfig(providerId);
54
+ const composed = config?.streamSimple;
55
+ if (config?.api === model.api && typeof composed === "function") {
56
+ return composed;
57
+ }
58
+ }
59
+ }
60
+ } catch {
61
+ // Incomplete host/test doubles still use the built-in compat dispatcher.
62
+ }
63
+
64
+ return compatStreamSimple;
65
+ }
@@ -82,7 +82,7 @@ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void
82
82
  "── Activity ──",
83
83
  `Next observation: ~${obsProgress.toLocaleString()} / ${runtime.config.observeAfterTokens.toLocaleString()} tokens (${pct(obsProgress, runtime.config.observeAfterTokens)}%)`,
84
84
  `Next reflection: ~${reflectionProgress.toLocaleString()} / ${runtime.config.reflectAfterTokens.toLocaleString()} tokens (${pct(reflectionProgress, runtime.config.reflectAfterTokens)}%)`,
85
- `Next compaction: ~${compactionProgress.toLocaleString()} / ${compactThreshold.toLocaleString()} tokens (${pct(compactionProgress, compactThreshold)}%)`,
85
+ `Next compaction: ~${compactionProgress.toLocaleString()} / ${compactThreshold.toLocaleString()} estimated source tokens (${pct(compactionProgress, compactThreshold)}%)`,
86
86
  `Visible observation pool: ~${visibleObservationTokens.toLocaleString()} / ${runtime.config.observationsPoolMaxTokens.toLocaleString()} tokens (${pct(visibleObservationTokens, runtime.config.observationsPoolMaxTokens)}%)`,
87
87
  `Active observation pool: ~${activeObservationPool.observationTokens.toLocaleString()} / ${runtime.config.observationsPoolTargetTokens.toLocaleString()} target tokens (${pct(activeObservationPool.observationTokens, runtime.config.observationsPoolTargetTokens)}%)`,
88
88
  `Reflection pool: ~${visibleReflectionTokens.toLocaleString()} tokens`,
package/src/config.ts CHANGED
@@ -33,13 +33,28 @@ export type CompactAfterTokensMode = "calibrated" | "ratio";
33
33
  export interface Config {
34
34
  observeAfterTokens: number;
35
35
  reflectAfterTokens: number;
36
+ /**
37
+ * Maximum estimated source tokens serialized into a single observer chunk.
38
+ * Unset (default) derives the cap from the resolved memory model's context
39
+ * window; see {@link resolveObserverChunkMaxTokens}.
40
+ */
41
+ observerChunkMaxTokens?: number;
36
42
  compactAfterTokens: number;
37
43
  compactAfterTokensMode: CompactAfterTokensMode;
38
44
  compactAfterTokensRatio: number;
39
45
  observationsPoolMaxTokens: number;
40
46
  observationsPoolTargetTokens: number;
41
47
  agentMaxTurns: number;
48
+ /**
49
+ * Maximum output tokens requested for background memory-agent loops
50
+ * (observer/reflector/dropper). Always clamped to the model's own
51
+ * `maxTokens` when available. Lower it for local servers with a modest
52
+ * context window, where concurrent sub-agent requests share KV with the
53
+ * main session and the default 32K response budget can overflow the slot.
54
+ */
55
+ agentMaxTokens: number;
42
56
  model?: ConfiguredModel;
57
+ showWorkerNotifications: boolean;
43
58
  passive: boolean;
44
59
  debugLog: boolean;
45
60
  }
@@ -53,6 +68,8 @@ export const DEFAULTS: Config = {
53
68
  observationsPoolMaxTokens: 20_000,
54
69
  observationsPoolTargetTokens: 10_000,
55
70
  agentMaxTurns: 16,
71
+ agentMaxTokens: 32_000,
72
+ showWorkerNotifications: true,
56
73
  passive: false,
57
74
  debugLog: false,
58
75
  };
@@ -76,7 +93,49 @@ export function resolveCompactAfterTokens(config: Config, contextWindow: number
76
93
  return config.compactAfterTokens;
77
94
  }
78
95
 
79
- export const THINKING_LEVEL_VALUES: readonly ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
96
+ export const THINKING_LEVEL_VALUES: readonly ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
97
+
98
+ /** Observer chunk cap used when no config is set and the model's context window is unknown. */
99
+ export const OBSERVER_CHUNK_FALLBACK_MAX_TOKENS = 60_000;
100
+
101
+ /** Smallest useful observer chunk: enough for labels, omission markers, and source context. */
102
+ export const OBSERVER_CHUNK_MIN_TOKENS = 256;
103
+
104
+ /**
105
+ * Fraction of the memory model's context window used for the derived observer
106
+ * chunk cap. Chunk sizes are estimated at ~4 chars/token, which can undercount
107
+ * real tokens by up to ~4x on non-ASCII content, so 0.2 keeps even the worst
108
+ * case at ~80% of the window with room left for the system prompt, prior
109
+ * memory, and the response.
110
+ */
111
+ export const OBSERVER_CHUNK_CONTEXT_RATIO = 0.2;
112
+
113
+ /**
114
+ * Resolve the maximum estimated tokens the observer serializes into one chunk.
115
+ *
116
+ * An explicit `observerChunkMaxTokens` config value always wins. Otherwise the
117
+ * cap is `floor(contextWindow * OBSERVER_CHUNK_CONTEXT_RATIO)` for the resolved
118
+ * memory model, falling back to {@link OBSERVER_CHUNK_FALLBACK_MAX_TOKENS} when
119
+ * the context window is unavailable.
120
+ *
121
+ * Without a cap, a backlog that outgrows the model's context window (e.g.
122
+ * after repeated observer failures, or when the extension is enabled mid-way
123
+ * into a long session) makes every observer call fail, so coverage never
124
+ * advances and the session can never recover. With the cap, oversized backlogs
125
+ * are drained oldest-first across successive runs.
126
+ */
127
+ export function resolveObserverChunkMaxTokens(config: Config, contextWindow: number | undefined): number {
128
+ if (config.observerChunkMaxTokens !== undefined && config.observerChunkMaxTokens > 0) {
129
+ return Math.max(OBSERVER_CHUNK_MIN_TOKENS, config.observerChunkMaxTokens);
130
+ }
131
+ if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) {
132
+ return Math.max(
133
+ OBSERVER_CHUNK_MIN_TOKENS,
134
+ Math.floor(contextWindow * OBSERVER_CHUNK_CONTEXT_RATIO),
135
+ );
136
+ }
137
+ return OBSERVER_CHUNK_FALLBACK_MAX_TOKENS;
138
+ }
80
139
 
81
140
  const SETTINGS_KEY = "observational-memory";
82
141
  const PASSIVE_ENV = "PI_OBSERVATIONAL_MEMORY_PASSIVE";
@@ -134,10 +193,12 @@ function normalizeSettingsConfig(value: Record<string, unknown>): Partial<Config
134
193
  const numberKeys = [
135
194
  "observeAfterTokens",
136
195
  "reflectAfterTokens",
196
+ "observerChunkMaxTokens",
137
197
  "compactAfterTokens",
138
198
  "observationsPoolMaxTokens",
139
199
  "observationsPoolTargetTokens",
140
200
  "agentMaxTurns",
201
+ "agentMaxTokens",
141
202
  ] as const;
142
203
  for (const key of numberKeys) {
143
204
  const normalizedValue = positiveIntegerOrUndefined(value[key]);
@@ -148,6 +209,7 @@ function normalizeSettingsConfig(value: Record<string, unknown>): Partial<Config
148
209
  }
149
210
  const ratio = validRatioOrUndefined(value.compactAfterTokensRatio);
150
211
  if (ratio !== undefined) normalized.compactAfterTokensRatio = ratio;
212
+ if (typeof value.showWorkerNotifications === "boolean") normalized.showWorkerNotifications = value.showWorkerNotifications;
151
213
  if (typeof value.passive === "boolean") normalized.passive = value.passive;
152
214
  if (typeof value.debugLog === "boolean") normalized.debugLog = value.debugLog;
153
215
  const model = normalizeModel(value.model);
@@ -1,4 +1,8 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ SessionBeforeCompactEvent,
5
+ } from "@earendil-works/pi-coding-agent";
2
6
 
3
7
  import type { Runtime } from "../runtime.js";
4
8
  import { buildCompactionProjection, renderSummary, type Entry } from "../session-ledger/index.js";
@@ -13,7 +17,7 @@ function observationsPoolMaxTokens(runtime: Runtime): number {
13
17
  }
14
18
 
15
19
  export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void {
16
- pi.on("session_before_compact", async (event: any, ctx: any) => {
20
+ pi.on("session_before_compact", async (event: SessionBeforeCompactEvent, ctx: ExtensionContext) => {
17
21
  if (runtime.compactHookInFlight) {
18
22
  if (ctx.hasUI) {
19
23
  ctx.ui.notify(
@@ -35,6 +39,10 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
35
39
  { observationsPoolMaxTokens: observationsPoolMaxTokens(runtime) },
36
40
  );
37
41
  const summary = renderSummary(projection.reflections, projection.observations);
42
+ if (summary.length === 0) {
43
+ // Decline ownership so Pi's native summarizer preserves the pre-cut context.
44
+ return;
45
+ }
38
46
 
39
47
  return {
40
48
  compaction: {