pi-observational-memory 3.0.4 → 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
  ```
@@ -227,6 +229,8 @@ A typical config:
227
229
 
228
230
  Most users can start with the defaults and tune only if they have a specific reason.
229
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
+
230
234
  ### Scaling compaction to the model's context window
231
235
 
232
236
  By default `compactAfterTokensMode` is `"calibrated"`, so the proactive
@@ -280,6 +284,7 @@ on the `Next compaction` line regardless of mode.
280
284
  | `observationsPoolMaxTokens` | `20000` | Observation-token budget used for compaction full-fold pressure. |
281
285
  | `observationsPoolTargetTokens` | half of max | Active observation target used by post-reflection dropper maintenance. |
282
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`. |
283
288
  | `model` | session model | Optional memory-worker model override: `{ provider, id, thinking }`. |
284
289
  | `showWorkerNotifications` | `true` | Shows routine observer, reflector, and dropper progress notifications. Warnings and errors are unaffected. |
285
290
  | `passive` | `false` | Disables proactive background observation, reflection, maintenance, and auto-compaction triggers. |
@@ -295,7 +300,7 @@ Valid `model.thinking` values are:
295
300
  * `xhigh`
296
301
  * `max`
297
302
 
298
- 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.
299
304
 
300
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.
301
306
 
@@ -329,14 +334,14 @@ flowchart TD
329
334
  Turn[turn_end]
330
335
  Observe[Capture observations]
331
336
  Reflect[Distill reflections]
332
- AgentEnd[agent_end]
337
+ AgentSettled[agent_settled]
333
338
  Trigger[auto-compaction trigger]
334
339
  Compact[session_before_compact]
335
340
  Summary[visible memory for Pi]
336
341
 
337
342
  Turn -->|observation due| Observe
338
343
  Turn -->|reflection due| Reflect
339
- AgentEnd -->|compactAfterTokens and idle| Trigger --> Compact --> Summary
344
+ AgentSettled -->|compactAfterTokens and idle| Trigger --> Compact --> Summary
340
345
  ```
341
346
 
342
347
  The high-level lifecycle:
@@ -363,7 +368,7 @@ Current behavior:
363
368
 
364
369
  * **Observation-centered memory.** The extension records useful session observations while you work.
365
370
  * **Durable reflections.** The extension distills stable facts that help the agent stay oriented over time.
366
- * **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.
367
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`.
368
373
  * **Source-backed recall.** Observations and reflections can be traced back through the `recall` tool.
369
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.4",
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",
@@ -1,11 +1,11 @@
1
1
  import { agentLoop, type AgentContext, type AgentLoopConfig, 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
- import { streamSimple } from "@earendil-works/pi-ai/compat";
5
4
  import type { Static } from "typebox";
6
5
  import { debugLog } from "../../debug-log.js";
7
6
  import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
8
7
  import { logAgentStreamError } from "../stream-errors.js";
8
+ import { resolveWorkerStreamSimple, type StreamableModelRegistry, type WorkerStreamSimple } from "../worker-stream.js";
9
9
  import { reflectionToSummaryLine, type Observation, type Reflection } from "../../session-ledger/index.js";
10
10
  import { DROPPER_SYSTEM } from "./prompts.js";
11
11
  import {
@@ -41,13 +41,18 @@ interface RunDropperArgs {
41
41
  model: Model<any>;
42
42
  apiKey?: string;
43
43
  headers?: Record<string, string>;
44
+ env?: Record<string, string>;
44
45
  reflections: Reflection[];
45
46
  observations: Observation[];
46
47
  targetTokens: number;
47
48
  signal?: AbortSignal;
48
49
  agentLoop?: typeof agentLoop;
49
50
  maxTurns?: number;
51
+ /** Maximum output tokens for the loop (defaults to {@link AGENT_LOOP_MAX_TOKENS}). */
52
+ maxOutputTokens?: number;
50
53
  thinkingLevel?: ModelThinkingLevel;
54
+ modelRegistry?: StreamableModelRegistry;
55
+ streamSimple?: WorkerStreamSimple;
51
56
  }
52
57
 
53
58
  const RELEVANCE_DROP_RANK: Record<Observation["relevance"], number> = {
@@ -131,7 +136,7 @@ export function selectDropCandidates(
131
136
  }
132
137
 
133
138
  export async function runDropper(args: RunDropperArgs): Promise<string[] | undefined> {
134
- const { model, apiKey, headers, reflections, observations, targetTokens, signal } = args;
139
+ const { model, apiKey, headers, env, reflections, observations, targetTokens, signal } = args;
135
140
  if (observations.length === 0) return undefined;
136
141
 
137
142
  const metrics = observationPoolMetrics(observations, targetTokens);
@@ -243,7 +248,8 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
243
248
  model,
244
249
  apiKey,
245
250
  headers,
246
- maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
251
+ env,
252
+ maxTokens: boundedMaxTokens(model, args.maxOutputTokens ?? AGENT_LOOP_MAX_TOKENS),
247
253
  convertToLlm: (msgs) => msgs as Message[],
248
254
  toolExecution: "sequential",
249
255
  ...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
@@ -251,7 +257,13 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
251
257
  };
252
258
 
253
259
  const loop = args.agentLoop ?? agentLoop;
254
- const stream = loop(prompts, context, config, signal, streamSimple);
260
+ const stream = loop(
261
+ prompts,
262
+ context,
263
+ config,
264
+ signal,
265
+ resolveWorkerStreamSimple(model, args.modelRegistry, args.streamSimple),
266
+ );
255
267
  for await (const event of stream) {
256
268
  // Tool execution collects candidate ids.
257
269
  logAgentStreamError("dropper", event);
@@ -1,10 +1,10 @@
1
1
  import { agentLoop, type AgentContext, type AgentLoopConfig, 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
- import { streamSimple } from "@earendil-works/pi-ai/compat";
5
4
  import type { Static } from "typebox";
6
5
  import { hashId } from "../../ids.js";
7
6
  import { logAgentStreamError } from "../stream-errors.js";
7
+ import { resolveWorkerStreamSimple, type StreamableModelRegistry, type WorkerStreamSimple } from "../worker-stream.js";
8
8
  import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
9
9
  import { OBSERVER_SYSTEM } from "./prompts.js";
10
10
  import { nowTimestamp, truncateRecordContent } from "../../serialize.js";
@@ -15,6 +15,7 @@ interface RunObserverArgs {
15
15
  model: Model<any>;
16
16
  apiKey?: string;
17
17
  headers?: Record<string, string>;
18
+ env?: Record<string, string>;
18
19
  priorReflections: string[];
19
20
  priorObservations: string[];
20
21
  chunk: string;
@@ -22,7 +23,11 @@ interface RunObserverArgs {
22
23
  signal?: AbortSignal;
23
24
  agentLoop?: typeof agentLoop;
24
25
  maxTurns?: number;
26
+ /** Maximum output tokens for the loop (defaults to {@link AGENT_LOOP_MAX_TOKENS}). */
27
+ maxOutputTokens?: number;
25
28
  thinkingLevel?: ModelThinkingLevel;
29
+ modelRegistry?: StreamableModelRegistry;
30
+ streamSimple?: WorkerStreamSimple;
26
31
  }
27
32
 
28
33
  const RelevanceSchema = Type.Union([
@@ -99,7 +104,7 @@ export function normalizeSourceEntryIds(
99
104
  }
100
105
 
101
106
  export async function runObserver(args: RunObserverArgs): Promise<Observation[] | undefined> {
102
- const { model, apiKey, headers, priorReflections, priorObservations, chunk, allowedSourceEntryIds, signal } = args;
107
+ const { model, apiKey, headers, env, priorReflections, priorObservations, chunk, allowedSourceEntryIds, signal } = args;
103
108
  const conversation = chunk.trim();
104
109
  if (!conversation) return undefined;
105
110
 
@@ -193,7 +198,8 @@ ${conversation}`;
193
198
  model,
194
199
  apiKey,
195
200
  headers,
196
- maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
201
+ env,
202
+ maxTokens: boundedMaxTokens(model, args.maxOutputTokens ?? AGENT_LOOP_MAX_TOKENS),
197
203
  convertToLlm: (msgs) => msgs as Message[],
198
204
  toolExecution: "sequential",
199
205
  ...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
@@ -208,7 +214,13 @@ ${conversation}`;
208
214
  };
209
215
 
210
216
  const loop = args.agentLoop ?? agentLoop;
211
- const stream = loop(prompts, context, config, signal, streamSimple);
217
+ const stream = loop(
218
+ prompts,
219
+ context,
220
+ config,
221
+ signal,
222
+ resolveWorkerStreamSimple(model, args.modelRegistry, args.streamSimple),
223
+ );
212
224
  let streamError: { stopReason: string; errorMessage?: string } | undefined;
213
225
  for await (const event of stream) {
214
226
  // Drain events; the tool's execute already collects records.
@@ -1,11 +1,11 @@
1
1
  import { agentLoop, type AgentContext, type AgentLoopConfig, 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
- import { streamSimple } from "@earendil-works/pi-ai/compat";
5
4
  import type { Static } from "typebox";
6
5
  import { debugLog } from "../../debug-log.js";
7
6
  import { hashId } from "../../ids.js";
8
7
  import { logAgentStreamError } from "../stream-errors.js";
8
+ import { resolveWorkerStreamSimple, type StreamableModelRegistry, type WorkerStreamSimple } from "../worker-stream.js";
9
9
  import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
10
10
  import { truncateRecordContent } from "../../serialize.js";
11
11
  import { REFLECTOR_SYSTEM } from "./prompts.js";
@@ -23,12 +23,17 @@ interface RunReflectorArgs {
23
23
  model: Model<any>;
24
24
  apiKey?: string;
25
25
  headers?: Record<string, string>;
26
+ env?: Record<string, string>;
26
27
  reflections: Reflection[];
27
28
  observations: Observation[];
28
29
  signal?: AbortSignal;
29
30
  agentLoop?: typeof agentLoop;
30
31
  maxTurns?: number;
32
+ /** Maximum output tokens for the loop (defaults to {@link AGENT_LOOP_MAX_TOKENS}). */
33
+ maxOutputTokens?: number;
31
34
  thinkingLevel?: ModelThinkingLevel;
35
+ modelRegistry?: StreamableModelRegistry;
36
+ streamSimple?: WorkerStreamSimple;
32
37
  }
33
38
 
34
39
  const RecordReflectionsSchema = Type.Object({
@@ -105,7 +110,7 @@ function normalizeReflectionContent(content: string): string | undefined {
105
110
  }
106
111
 
107
112
  export async function runReflector(args: RunReflectorArgs): Promise<Reflection[] | undefined> {
108
- const { model, apiKey, headers, reflections, observations, signal } = args;
113
+ const { model, apiKey, headers, env, reflections, observations, signal } = args;
109
114
  if (observations.length === 0) return undefined;
110
115
 
111
116
  const coverageById = reflectionCoverageMap(observations, reflections);
@@ -176,7 +181,8 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
176
181
  model,
177
182
  apiKey,
178
183
  headers,
179
- maxTokens: boundedMaxTokens(model, AGENT_LOOP_MAX_TOKENS),
184
+ env,
185
+ maxTokens: boundedMaxTokens(model, args.maxOutputTokens ?? AGENT_LOOP_MAX_TOKENS),
180
186
  convertToLlm: (msgs) => msgs as Message[],
181
187
  toolExecution: "sequential",
182
188
  ...(reasoning && thinkingLevel !== "off" ? { reasoning: thinkingLevel } : {}),
@@ -184,7 +190,13 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
184
190
  };
185
191
 
186
192
  const loop = args.agentLoop ?? agentLoop;
187
- const stream = loop(prompts, context, config, signal, streamSimple);
193
+ const stream = loop(
194
+ prompts,
195
+ context,
196
+ config,
197
+ signal,
198
+ resolveWorkerStreamSimple(model, args.modelRegistry, args.streamSimple),
199
+ );
188
200
  for await (const event of stream) {
189
201
  // Tool execution collects records.
190
202
  logAgentStreamError("reflector", event);
@@ -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
+ }
package/src/config.ts CHANGED
@@ -45,6 +45,14 @@ export interface Config {
45
45
  observationsPoolMaxTokens: number;
46
46
  observationsPoolTargetTokens: number;
47
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;
48
56
  model?: ConfiguredModel;
49
57
  showWorkerNotifications: boolean;
50
58
  passive: boolean;
@@ -60,6 +68,7 @@ export const DEFAULTS: Config = {
60
68
  observationsPoolMaxTokens: 20_000,
61
69
  observationsPoolTargetTokens: 10_000,
62
70
  agentMaxTurns: 16,
71
+ agentMaxTokens: 32_000,
63
72
  showWorkerNotifications: true,
64
73
  passive: false,
65
74
  debugLog: false,
@@ -189,6 +198,7 @@ function normalizeSettingsConfig(value: Record<string, unknown>): Partial<Config
189
198
  "observationsPoolMaxTokens",
190
199
  "observationsPoolTargetTokens",
191
200
  "agentMaxTurns",
201
+ "agentMaxTokens",
192
202
  ] as const;
193
203
  for (const key of numberKeys) {
194
204
  const normalizedValue = positiveIntegerOrUndefined(value[key]);
@@ -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: {
@@ -3,35 +3,14 @@ import { resolveCompactAfterTokens } from "../config.js";
3
3
  import { rawTokensSinceLastCompaction, type Entry } from "../session-ledger/index.js";
4
4
  import type { Runtime } from "../runtime.js";
5
5
 
6
- /**
7
- * Regex matching Pi's internal retryable error detection.
8
- * When the last assistant message in agent_end has stopReason "error" matching this pattern,
9
- * Pi will auto-retry — we must not trigger compaction between attempts.
10
- */
11
- const RETRYABLE_ERROR_RE =
12
- /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
13
-
14
6
  export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
15
- pi.on("agent_end", (event: any, ctx: any) => {
7
+ // Pi emits agent_settled only after retries, automatic compaction, and queued
8
+ // continuation have finished, so retry policy stays owned by Pi.
9
+ pi.on("agent_settled", (_event, ctx) => {
16
10
  runtime.ensureConfig(ctx.cwd);
17
11
  if (runtime.config.passive === true) return;
18
12
  if (runtime.compactInFlight) return;
19
13
 
20
- // Don't trigger compaction if Pi will auto-retry — the agent hasn't truly finished.
21
- // Pi emits agent_end before its own retry check, so we must detect this ourselves.
22
- // The next agent_end (after retry succeeds or exhausts attempts) will re-evaluate.
23
- const lastAssistant = [...event.messages].reverse().find(
24
- (m): m is Extract<typeof m, { role: "assistant" }> => m.role === "assistant",
25
- );
26
- if (
27
- lastAssistant
28
- && lastAssistant.stopReason === "error"
29
- && lastAssistant.errorMessage
30
- && RETRYABLE_ERROR_RE.test(lastAssistant.errorMessage)
31
- ) {
32
- return;
33
- }
34
-
35
14
  const entries = ctx.sessionManager?.getBranch?.() as Entry[] | undefined;
36
15
  if (!entries) return;
37
16
  const progress = rawTokensSinceLastCompaction(entries);
@@ -124,6 +124,22 @@ function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "ob
124
124
  });
125
125
  if (cached.ok) {
126
126
  runtime.resolveFailureNotified = false;
127
+ // Console Go (opencode.ai) rejects requests without x-opencode-session
128
+ // (400 MissingSessionID). Mirror pi's own session headers on worker calls.
129
+ const model = (cached.model ?? {}) as { provider?: string; baseUrl?: string };
130
+ if (model.provider === "opencode" || model.provider === "opencode-go" || (typeof model.baseUrl === "string" && model.baseUrl.includes("opencode.ai"))) {
131
+ const sessionId = ctx.sessionManager.getSessionId?.();
132
+ if (sessionId) {
133
+ return {
134
+ ...cached,
135
+ headers: {
136
+ ...(cached.headers ?? {}),
137
+ "x-opencode-session": sessionId,
138
+ "x-opencode-client": "pi",
139
+ },
140
+ };
141
+ }
142
+ }
127
143
  return cached;
128
144
  }
129
145
  debugLog(`${stage}.model_unavailable`, { reason: cached.reason });
@@ -311,12 +327,15 @@ async function runObserverStage(
311
327
  model: resolved.model as any,
312
328
  apiKey: resolved.apiKey,
313
329
  headers: resolved.headers,
330
+ env: resolved.env,
314
331
  priorReflections,
315
332
  priorObservations,
316
333
  chunk,
317
334
  allowedSourceEntryIds: sourceEntryIds,
318
335
  maxTurns: runtime.config.agentMaxTurns,
336
+ maxOutputTokens: runtime.config.agentMaxTokens,
319
337
  thinkingLevel: runtime.config.model?.thinking ?? "low",
338
+ modelRegistry: ctx.modelRegistry,
320
339
  });
321
340
  } catch (error) {
322
341
  if (error instanceof ObserverStreamError) {
@@ -383,10 +402,13 @@ async function runReflectorStage(
383
402
  model: resolved.model as any,
384
403
  apiKey: resolved.apiKey,
385
404
  headers: resolved.headers,
405
+ env: resolved.env,
386
406
  reflections: folded.reflections,
387
407
  observations: folded.activeObservations,
388
408
  maxTurns: runtime.config.agentMaxTurns,
409
+ maxOutputTokens: runtime.config.agentMaxTokens,
389
410
  thinkingLevel: runtime.config.model?.thinking ?? "low",
411
+ modelRegistry: ctx.modelRegistry,
390
412
  });
391
413
  if (!reflections) return { outcome: "continue", sameRunReflections: [] };
392
414
 
@@ -455,11 +477,14 @@ async function runDropperStage(
455
477
  model: resolved.model as any,
456
478
  apiKey: resolved.apiKey,
457
479
  headers: resolved.headers,
480
+ env: resolved.env,
458
481
  reflections: reflectionsForDropper,
459
482
  observations: folded.activeObservations,
460
483
  targetTokens: runtime.config.observationsPoolTargetTokens,
461
484
  maxTurns: runtime.config.agentMaxTurns,
485
+ maxOutputTokens: runtime.config.agentMaxTokens,
462
486
  thinkingLevel: runtime.config.model?.thinking ?? "low",
487
+ modelRegistry: ctx.modelRegistry,
463
488
  });
464
489
  const coversUpToId = earlierCoverageMarkerId(entries, observationCoverageId, sameRunReflectionCoverageId);
465
490
  const data = coversUpToId && droppedIds ? buildObservationsDroppedData(droppedIds, coversUpToId) : undefined;
package/src/runtime.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type Config, DEFAULTS, loadConfig } from "./config.js";
2
+ import { debugLog } from "./debug-log.js";
2
3
 
3
4
  export type ResolveResult =
4
- | { ok: true; model: unknown; apiKey?: string; headers?: Record<string, string> }
5
+ | { ok: true; model: unknown; apiKey?: string; headers?: Record<string, string>; env?: Record<string, string>; baseUrl?: string }
5
6
  | { ok: false; reason: string };
6
7
 
7
8
  /**
@@ -10,21 +11,79 @@ export type ResolveResult =
10
11
  * OAuth providers (kimi-coding, xai, openai-codex, anthropic OAuth, …) authenticate via
11
12
  * `toAuth()` returning `{ headers: { Authorization: "Bearer …" } }` with no apiKey, and
12
13
  * pi-ai providers accept a caller-supplied Authorization header in place of an apiKey.
14
+ *
15
+ * NOTE: a `false` result does NOT mean "unauthenticated" — see `resolveModel`. Providers
16
+ * that authenticate at request time (Amazon Bedrock SigV4 from AWS_PROFILE/SSO, Google
17
+ * Vertex ADC) legitimately expose neither an apiKey nor a header, because pi signs their
18
+ * requests itself.
13
19
  */
14
20
  function hasUsableAuth(auth: { apiKey?: unknown; headers?: unknown }): boolean {
15
21
  if (typeof auth.apiKey === "string" && auth.apiKey.length > 0) return true;
16
- if (auth.headers && typeof auth.headers === "object") {
17
- return Object.values(auth.headers as Record<string, unknown>).some(
18
- (value) => typeof value === "string" && value.length > 0,
19
- );
20
- }
21
- return false;
22
+ return countUsableHeaders(auth.headers) > 0;
23
+ }
24
+
25
+ /** How many headers the auth payload carries at all (diagnostics only, never values). */
26
+ function countHeaders(headers: unknown): number {
27
+ return headers && typeof headers === "object" ? Object.keys(headers as Record<string, unknown>).length : 0;
22
28
  }
23
29
 
30
+ /** How many headers carry a non-empty string value — the ones pi could actually send. */
31
+ function countUsableHeaders(headers: unknown): number {
32
+ if (!headers || typeof headers !== "object") return 0;
33
+ return Object.values(headers as Record<string, unknown>).filter(
34
+ (value) => typeof value === "string" && value.length > 0,
35
+ ).length;
36
+ }
37
+
38
+ /**
39
+ * How long to wait for the availability re-check in `recheckProviderCredential`, and how
40
+ * long before the same provider may be re-checked again.
41
+ *
42
+ * The re-check is network-free and measured at ~1ms on a warm Bedrock/SSO host, but
43
+ * `checkAuth` can block on a provider's own credential resolution, so it is bounded. The
44
+ * re-arm interval keeps an unauthenticated host from paying the cost on every
45
+ * consolidation while still recovering within a session when credentials are renewed out
46
+ * of band (`aws sso login` in another terminal, `gcloud auth application-default login`).
47
+ */
48
+ const AVAILABILITY_RECHECK_TIMEOUT_MS = 5_000;
49
+ const AVAILABILITY_RECHECK_REARM_MS = 60_000;
50
+
24
51
  type NotifyLevel = "warning" | "info" | "error";
25
52
  type Notify = (message: string, type?: NotifyLevel) => void;
26
53
  export type ConsolidationPhase = "observer" | "reflector" | "dropper";
27
54
 
55
+ /**
56
+ * Whether pi positively reports a working credential source for this model's provider.
57
+ *
58
+ * `ModelRegistry.hasConfiguredAuth(model)` is true when pi's availability check
59
+ * (`ModelRuntime.checkAuth`) resolved *something* for the provider — an API key, a
60
+ * stored credential, or an ambient source such as `AWS_PROFILE` / `AWS_ACCESS_KEY_ID`
61
+ * / gcloud ADC. Combined with `auth.ok === true` and an auth payload that carries
62
+ * nothing, that is the signature of a provider pi signs at request time:
63
+ *
64
+ * pi has a credential source, and deliberately hands the caller nothing to attach.
65
+ *
66
+ * Measured on a Bedrock/SSO host (pi 0.84.2), with `AWS_PROFILE` exported:
67
+ * checkAuth("amazon-bedrock") -> { source: "AWS_PROFILE", type: "api_key" }
68
+ * hasConfiguredAuth(model) -> true
69
+ * getApiKeyAndHeaders(model) -> { ok: true, apiKey: undefined, headers: undefined }
70
+ * `googleVertexProvider`'s ADC branch returns the same empty-auth resolution.
71
+ *
72
+ * The inverse case — `hasConfiguredAuth === false` with an empty auth payload — is a
73
+ * provider pi could not authenticate at all (no key, no ambient source). That must
74
+ * keep failing: it is the ordinary "not logged in" state, not ambient auth.
75
+ *
76
+ * Defensive: older pi versions and partial test doubles may not expose this, and an
77
+ * unknown answer must not be read as "authenticated".
78
+ */
79
+ function hasConfiguredProviderCredential(registry: unknown, model: unknown): boolean {
80
+ try {
81
+ return (registry as { hasConfiguredAuth?: (m: unknown) => unknown }).hasConfiguredAuth?.(model) === true;
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
28
87
  export interface ResolveCtx {
29
88
  model: unknown;
30
89
  modelRegistry: any;
@@ -49,6 +108,8 @@ export class Runtime {
49
108
  lastObserverError: string | undefined;
50
109
  lastReflectorError: string | undefined;
51
110
  lastDropperError: string | undefined;
111
+ /** provider -> epoch ms of the last availability re-check (see `recheckProviderCredential`). */
112
+ availabilityRecheckedAt = new Map<string, number>();
52
113
  /** Deliberate-empty backoff (#23): skip observer re-fires over the same span until enough new tokens arrive. */
53
114
  observerEmptyBackoff: {
54
115
  sessionIdentity: string | undefined;
@@ -78,14 +139,148 @@ export class Runtime {
78
139
  if (!model) return { ok: false, reason: "no model available (session has no model and no observational-memory model configured)" };
79
140
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
80
141
  const provider = (model as { provider?: string }).provider ?? "unknown";
81
- if (!auth.ok || !hasUsableAuth(auth)) {
82
- const isOAuth = ctx.modelRegistry.isUsingOAuth?.(model) === true;
142
+ const isOAuth = ctx.modelRegistry.isUsingOAuth?.(model) === true;
143
+ // `auth.ok === false` is the only unambiguous failure: pi returns it when a
144
+ // provider requires a request auth header and no credential resolved.
145
+ //
146
+ // `auth.ok === true` with neither apiKey nor headers, for a provider pi DOES
147
+ // report a credential source for, is not a failure — it is how pi describes a
148
+ // provider that authenticates at request time: Amazon Bedrock signing SigV4 from
149
+ // ambient AWS credentials (`bedrockAuth.resolve` returns `{ auth: {}, source:
150
+ // "AWS_PROFILE" }`), Google Vertex using ADC (same empty resolution). pi's own
151
+ // native streaming path forwards no apiKey either, and its pre-prompt gate is
152
+ // merely `hasConfiguredAuth(provider) || checkAuth(provider) !== undefined` —
153
+ // om's pre-flight check must not be stricter than pi's own. Treating it as "no
154
+ // auth" aborted consolidation before the model was ever called, disabling
155
+ // observational memory silently — no error, no cost, no latency — on such hosts.
156
+ //
157
+ // Three cases deliberately keep failing: OAuth providers, where an empty
158
+ // resolution means the credentials no longer resolve and the user must log in
159
+ // again; a credential that resolved to an empty *string* key, which is a
160
+ // misconfiguration rather than ambient auth; and a provider pi reports no
161
+ // credential source for at all, which is simply unauthenticated.
162
+ const usable = hasUsableAuth(auth);
163
+ const resolvedEmptyApiKey = typeof auth.apiKey === "string" && auth.apiKey.length === 0;
164
+ let providerCredentialConfigured = hasConfiguredProviderCredential(ctx.modelRegistry, model);
165
+ // pi's gate has TWO halves and never trusts the snapshot alone (agent-session.js):
166
+ //
167
+ // hasConfiguredAuth(provider) || (await checkAuth(provider)) !== undefined
168
+ //
169
+ // `hasConfiguredAuth` reads `snapshot.configuredProviders`, which is populated by an
170
+ // availability pass — and left untouched when that pass is skipped
171
+ // (`refreshOnCreate: false`), aborted, or FAILS (its catch records `availabilityError`
172
+ // and returns). A provider whose credential could not be checked at startup — an
173
+ // expired SSO token, say — is therefore absent from the snapshot for the rest of the
174
+ // session, even after the user renews it out of band. pi recovers on the next turn
175
+ // because its second half re-checks live; reading only the snapshot half would leave
176
+ // consolidation dead for the whole session, which is the same silent-failure class as
177
+ // the bug this gate was fixed for.
178
+ //
179
+ // The facade exposes no `checkAuth`, but `refresh({ providers })` performs the same
180
+ // live check and then updates the snapshot, so re-reading afterwards is equivalent.
181
+ // Only attempted when everything else already looks like the ambient shape, so an
182
+ // ordinary unauthenticated provider still fails on the first call.
183
+ if (auth.ok === true && !usable && !isOAuth && !resolvedEmptyApiKey && !providerCredentialConfigured) {
184
+ providerCredentialConfigured = await this.recheckProviderCredential(ctx.modelRegistry, model, provider);
185
+ }
186
+ const signsAtRequestTime =
187
+ auth.ok === true && !isOAuth && !resolvedEmptyApiKey && providerCredentialConfigured;
188
+ if (!auth.ok || (!usable && !signsAtRequestTime)) {
83
189
  const reason = isOAuth
84
190
  ? `authentication failed for provider "${provider}" — OAuth credentials may have expired; run '/login ${provider}' to re-authenticate`
85
191
  : `no API key or auth headers for provider "${provider}"`;
192
+ // The reason string alone cannot tell `ok: false` from `ok: true` with nothing to
193
+ // carry, which is what made the ambient-credential outage un-diagnosable from the
194
+ // debug log. Record the decision inputs — booleans and counts only, never values.
195
+ debugLog("resolve.rejected", {
196
+ provider,
197
+ reason,
198
+ authOk: auth.ok === true,
199
+ hasApiKey: typeof auth.apiKey === "string" && auth.apiKey.length > 0,
200
+ resolvedEmptyApiKey,
201
+ headerCount: countHeaders(auth.headers),
202
+ usableHeaderCount: countUsableHeaders(auth.headers),
203
+ isOAuth,
204
+ providerCredentialConfigured,
205
+ signsAtRequestTime,
206
+ });
86
207
  return { ok: false, reason };
87
208
  }
88
- return { ok: true, model, apiKey: auth.apiKey as string | undefined, headers: auth.headers as Record<string, string> | undefined };
209
+ if (!usable) {
210
+ debugLog("resolve.request_time_signing", { provider, providerCredentialConfigured });
211
+ }
212
+ // Match pi's request model: OAuth may route to an account-specific endpoint
213
+ // (e.g. Copilot Business). Do not mutate the shared session/registry model.
214
+ const requestModel = auth.baseUrl ? { ...(model as object), baseUrl: auth.baseUrl } : model;
215
+ return {
216
+ ok: true,
217
+ model: requestModel,
218
+ apiKey: auth.apiKey as string | undefined,
219
+ headers: auth.headers as Record<string, string> | undefined,
220
+ env: auth.env as Record<string, string> | undefined,
221
+ baseUrl: auth.baseUrl as string | undefined,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Re-check one provider's credential live, then re-read pi's snapshot.
227
+ *
228
+ * Implements the second half of pi's own auth gate for the only case that needs it: an
229
+ * otherwise-ambient-looking resolution whose provider is missing from a stale or never
230
+ * populated availability snapshot. Bounded and rate-limited; never throws.
231
+ */
232
+ private async recheckProviderCredential(registry: unknown, model: unknown, provider: string): Promise<boolean> {
233
+ const last = this.availabilityRecheckedAt.get(provider);
234
+ const now = Date.now();
235
+ if (last !== undefined && now - last < AVAILABILITY_RECHECK_REARM_MS) return false;
236
+ this.availabilityRecheckedAt.set(provider, now);
237
+
238
+ const refresh = (registry as { refresh?: (options?: unknown) => Promise<unknown> }).refresh;
239
+ if (typeof refresh !== "function") {
240
+ debugLog("resolve.availability_recheck", { provider, refreshed: false, reason: "registry exposes no refresh()" });
241
+ return false;
242
+ }
243
+
244
+ const controller = new AbortController();
245
+ const timer = setTimeout(() => controller.abort(), AVAILABILITY_RECHECK_TIMEOUT_MS);
246
+ let refreshError: string | undefined;
247
+ let timedOut = false;
248
+ try {
249
+ // allowNetwork:false — a credential re-check must not wait on a model-catalog fetch.
250
+ // providers:[provider] — scope the work, and the snapshot writes, to the one provider.
251
+ //
252
+ // Both are honoured from pi 0.84; on pi 0.81 the facade is `refresh()` with no
253
+ // parameters, delegating to `runtime.reloadConfig()`, which reloads models.json and
254
+ // then runs a FULL, network-permitted availability pass. Passing the options is
255
+ // harmless there, but the work is wider and slower — hence the race below rather
256
+ // than relying on the abort signal, which that version never sees.
257
+ await Promise.race([
258
+ refresh.call(registry, { allowNetwork: false, providers: [provider], signal: controller.signal }),
259
+ new Promise<void>((resolve) => {
260
+ controller.signal.addEventListener("abort", () => {
261
+ timedOut = true;
262
+ resolve();
263
+ });
264
+ }),
265
+ ]);
266
+ } catch (error) {
267
+ refreshError = error instanceof Error ? error.message : String(error);
268
+ } finally {
269
+ clearTimeout(timer);
270
+ }
271
+
272
+ // Re-read even when the refresh reported an error or timed out: a scoped pass can
273
+ // update the snapshot for this provider and still fail elsewhere.
274
+ const recovered = hasConfiguredProviderCredential(registry, model);
275
+ debugLog("resolve.availability_recheck", {
276
+ provider,
277
+ refreshed: refreshError === undefined && !timedOut,
278
+ recovered,
279
+ elapsedMs: Date.now() - now,
280
+ timedOut,
281
+ ...(refreshError === undefined ? {} : { refreshError }),
282
+ });
283
+ return recovered;
89
284
  }
90
285
 
91
286
  launchConsolidationTask(ctx: LaunchCtx, work: () => Promise<void>): Promise<void> {