pi-observational-memory 3.0.2 → 3.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -208,6 +208,8 @@ A typical config:
208
208
  "observeAfterTokens": 10000,
209
209
  "reflectAfterTokens": 20000,
210
210
  "compactAfterTokens": 81000,
211
+ "compactAfterTokensMode": "calibrated",
212
+ "compactAfterTokensRatio": 0.68,
211
213
  "observationsPoolMaxTokens": 20000,
212
214
  "observationsPoolTargetTokens": 10000,
213
215
  "agentMaxTurns": 16,
@@ -216,6 +218,7 @@ A typical config:
216
218
  "id": "google/gemma-4-31b-it",
217
219
  "thinking": "low"
218
220
  },
221
+ "showWorkerNotifications": true,
219
222
  "passive": false,
220
223
  "debugLog": false
221
224
  }
@@ -224,17 +227,61 @@ A typical config:
224
227
 
225
228
  Most users can start with the defaults and tune only if they have a specific reason.
226
229
 
230
+ ### Scaling compaction to the model's context window
231
+
232
+ By default `compactAfterTokensMode` is `"calibrated"`, so the proactive
233
+ compaction trigger uses the fixed `compactAfterTokens` estimated source-entry
234
+ threshold (81,000 by default). This preserves the pre-PR #40 compaction metric
235
+ for typical ~128K–200K context models.
236
+
237
+ On a large-context model (e.g. 1M tokens) the calibrated default preempts
238
+ compaction at ~81K, wasting most of the window. Switch to `"ratio"` mode to let
239
+ the trigger scale with the active model's `contextWindow`:
240
+
241
+ ```json
242
+ {
243
+ "observational-memory": {
244
+ "compactAfterTokens": 81000,
245
+ "compactAfterTokensMode": "ratio",
246
+ "compactAfterTokensRatio": 0.5
247
+ }
248
+ }
249
+ ```
250
+
251
+ In ratio mode the effective threshold is
252
+ `floor(model.contextWindow * compactAfterTokensRatio)` (clamped to a minimum of
253
+ 1). With the example above, a 1,000,000-token window compacts after about
254
+ 500,000 estimated source-entry tokens after the latest compaction boundary; a
255
+ 200,000-token window uses about 100,000. The threshold counts source entries,
256
+ not Pi's system prompt, tool schemas, or provider accounting. Pi's native
257
+ window-pressure compaction remains independent.
258
+
259
+ `compactAfterTokensRatio` is user-tunable precisely because **context window ≠
260
+ attention**. Some models advertise a large window but degrade at long range; set
261
+ a lower ratio (e.g. `0.4`) to compact earlier on those, or a higher ratio
262
+ (e.g. `0.7`) on models that stay sharp. The default ratio is `0.68`.
263
+
264
+ `compactAfterTokens` is always retained as the fallback: in `"calibrated"`
265
+ mode it is the threshold directly, and in `"ratio"` mode it is used whenever
266
+ the active model's `contextWindow` is unavailable (undefined, 0, or negative),
267
+ so compaction still triggers safely. `/om:status` shows the resolved threshold
268
+ on the `Next compaction` line regardless of mode.
269
+
227
270
  ### Defaults
228
271
 
229
272
  | Setting | Default | Meaning |
230
273
  | --------------------------- | ------------- | ------------------------------------------------------------------------------------------------- |
231
274
  | `observeAfterTokens` | `10000` | Raw/source token threshold for observation runs. |
275
+ | `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. |
232
276
  | `reflectAfterTokens` | `20000` | Raw/source token threshold for reflection runs; successful reflection creates dropper opportunities. |
233
- | `compactAfterTokens` | `81000` | Raw/source token threshold for proactive auto-compaction. |
277
+ | `compactAfterTokens` | `81000` | Estimated source-entry threshold for proactive auto-compaction, counted after the latest compaction boundary. |
278
+ | `compactAfterTokensMode` | `"calibrated"`| `"calibrated"` uses `compactAfterTokens` directly. `"ratio"` scales the source-entry threshold by the active model's `contextWindow`. |
279
+ | `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)`. |
234
280
  | `observationsPoolMaxTokens` | `20000` | Observation-token budget used for compaction full-fold pressure. |
235
281
  | `observationsPoolTargetTokens` | half of max | Active observation target used by post-reflection dropper maintenance. |
236
282
  | `agentMaxTurns` | `16` | Shared turn cap for background memory-agent loops. |
237
283
  | `model` | session model | Optional memory-worker model override: `{ provider, id, thinking }`. |
284
+ | `showWorkerNotifications` | `true` | Shows routine observer, reflector, and dropper progress notifications. Warnings and errors are unaffected. |
238
285
  | `passive` | `false` | Disables proactive background observation, reflection, maintenance, and auto-compaction triggers. |
239
286
  | `debugLog` | `false` | Writes opt-in per-session extension debug events to Pi's agent directory. |
240
287
 
@@ -246,9 +293,12 @@ Valid `model.thinking` values are:
246
293
  * `medium`
247
294
  * `high`
248
295
  * `xhigh`
296
+ * `max`
249
297
 
250
298
  If no `model` is configured, memory workers use the session model.
251
299
 
300
+ 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
+
252
302
  `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.
253
303
 
254
304
  Dropper pruning balances age, relevance, and reflection coverage. Relevance is importance/resistance, not a permanent active-memory pin: `critical` observations require the strongest evidence but can be dropped when they are older and safely represented by reflections, superseded by newer memory, redundant, or obsolete. Dropper input annotates each active observation with deterministic coverage evidence: `none`, `partial`, or `strong`; coverage guides model judgment and is not an automatic drop rule. Dropping removes observations from active memory, not ledger history.
@@ -299,6 +349,12 @@ The high-level lifecycle:
299
349
 
300
350
  The important part: compaction does not need to rethink the whole session from scratch.
301
351
 
352
+ The proactive compaction threshold counts estimated source-entry tokens after
353
+ the latest compaction boundary. It includes source entries retained by
354
+ `firstKeptEntryId` and newer source entries, while memory ledger entries and
355
+ compaction metadata contribute zero. `/om:status` uses the same metric. Pi's
356
+ own window-pressure compaction remains independent.
357
+
302
358
  ---
303
359
 
304
360
  ## Current V3 behavior
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-observational-memory",
3
- "version": "3.0.2",
3
+ "version": "3.0.4",
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",
@@ -1,9 +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";
4
5
  import type { Static } from "typebox";
5
6
  import { debugLog } from "../../debug-log.js";
6
7
  import { AGENT_LOOP_MAX_TOKENS, boundedMaxTokens } from "../../model-budget.js";
8
+ import { logAgentStreamError } from "../stream-errors.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,7 +39,7 @@ 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>;
42
44
  reflections: Reflection[];
43
45
  observations: Observation[];
@@ -249,9 +251,10 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
249
251
  };
250
252
 
251
253
  const loop = args.agentLoop ?? agentLoop;
252
- const stream = loop(prompts, context, config, signal);
253
- for await (const _event of stream) {
254
+ const stream = loop(prompts, context, config, signal, streamSimple);
255
+ for await (const event of stream) {
254
256
  // Tool execution collects candidate ids.
257
+ logAgentStreamError("dropper", event);
255
258
  }
256
259
  await stream.result();
257
260
  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 {
@@ -1,17 +1,19 @@
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";
4
5
  import type { Static } from "typebox";
5
6
  import { hashId } from "../../ids.js";
7
+ import { logAgentStreamError } from "../stream-errors.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>;
16
18
  priorReflections: string[];
17
19
  priorObservations: string[];
@@ -60,6 +62,21 @@ const RecordObservationsSchema = Type.Object({
60
62
 
61
63
  type RecordObservationsArgs = Static<typeof RecordObservationsSchema>;
62
64
 
65
+ /**
66
+ * Thrown when the agent loop ends with an API/stream failure (`stopReason`
67
+ * `"error"`/`"aborted"`) without recording anything. agent-core returns such
68
+ * runs normally, so without this the caller cannot tell a hard failure from a
69
+ * deliberate empty result (#32).
70
+ */
71
+ export class ObserverStreamError extends Error {
72
+ readonly stopReason: string;
73
+ constructor(stopReason: string, errorMessage?: string) {
74
+ super(`observer stream ended with stopReason "${stopReason}"${errorMessage ? `: ${errorMessage}` : ""}`);
75
+ this.name = "ObserverStreamError";
76
+ this.stopReason = stopReason;
77
+ }
78
+ }
79
+
63
80
  function joinOrEmpty(items: string[]): string {
64
81
  return items.length ? items.join("\n") : "(none yet)";
65
82
  }
@@ -118,7 +135,12 @@ export async function runObserver(args: RunObserverArgs): Promise<Observation[]
118
135
  timestamp: obs.timestamp,
119
136
  relevance: obs.relevance as Relevance,
120
137
  sourceEntryIds,
121
- tokenCount: estimateStringTokens(content),
138
+ tokenCount: observationLineTokenCount({
139
+ id,
140
+ timestamp: obs.timestamp,
141
+ relevance: obs.relevance,
142
+ content,
143
+ }),
122
144
  });
123
145
  added++;
124
146
  }
@@ -186,12 +208,23 @@ ${conversation}`;
186
208
  };
187
209
 
188
210
  const loop = args.agentLoop ?? agentLoop;
189
- const stream = loop(prompts, context, config, signal);
190
- for await (const _event of stream) {
211
+ const stream = loop(prompts, context, config, signal, streamSimple);
212
+ let streamError: { stopReason: string; errorMessage?: string } | undefined;
213
+ for await (const event of stream) {
191
214
  // Drain events; the tool's execute already collects records.
215
+ logAgentStreamError("observer", event);
216
+ // Watch for a terminal API/stream failure so it is not conflated with
217
+ // a deliberate empty result.
218
+ const message = (event as { message?: { role?: string; stopReason?: string; errorMessage?: string } }).message;
219
+ if (message?.role === "assistant" && (message.stopReason === "error" || message.stopReason === "aborted")) {
220
+ streamError = { stopReason: message.stopReason, errorMessage: message.errorMessage };
221
+ }
192
222
  }
193
223
  await stream.result();
194
224
 
195
- if (accumulated.size === 0) return undefined;
225
+ if (accumulated.size === 0) {
226
+ if (streamError) throw new ObserverStreamError(streamError.stopReason, streamError.errorMessage);
227
+ return undefined;
228
+ }
196
229
  return Array.from(accumulated.values());
197
230
  }
@@ -1,9 +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";
4
5
  import type { Static } from "typebox";
5
6
  import { debugLog } from "../../debug-log.js";
6
7
  import { hashId } from "../../ids.js";
8
+ import { logAgentStreamError } from "../stream-errors.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,7 +21,7 @@ import {
19
21
 
20
22
  interface RunReflectorArgs {
21
23
  model: Model<any>;
22
- apiKey: string;
24
+ apiKey?: string;
23
25
  headers?: Record<string, string>;
24
26
  reflections: Reflection[];
25
27
  observations: Observation[];
@@ -182,9 +184,10 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
182
184
  };
183
185
 
184
186
  const loop = args.agentLoop ?? agentLoop;
185
- const stream = loop(prompts, context, config, signal);
186
- for await (const _event of stream) {
187
+ const stream = loop(prompts, context, config, signal, streamSimple);
188
+ for await (const event of stream) {
187
189
  // Tool execution collects records.
190
+ logAgentStreamError("reflector", event);
188
191
  }
189
192
  await stream.result();
190
193
  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
+ }
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { observationPoolMetrics } from "../agents/dropper/pool.js";
3
+ import { resolveCompactAfterTokens } from "../config.js";
3
4
  import type { Runtime } from "../runtime.js";
4
5
  import {
5
6
  diffProjection,
@@ -61,6 +62,8 @@ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void
61
62
  const obsProgress = rawTokensSinceObservationCoverage(entries);
62
63
  const reflectionProgress = rawTokensSinceReflectionCoverage(entries);
63
64
  const compactionProgress = rawTokensSinceLastCompaction(entries);
65
+ const contextWindow = typeof ctx.model?.contextWindow === "number" ? ctx.model.contextWindow : undefined;
66
+ const compactThreshold = resolveCompactAfterTokens(runtime.config, contextWindow);
64
67
 
65
68
  const passiveLines = runtime.config.passive === true
66
69
  ? [
@@ -79,7 +82,7 @@ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void
79
82
  "── Activity ──",
80
83
  `Next observation: ~${obsProgress.toLocaleString()} / ${runtime.config.observeAfterTokens.toLocaleString()} tokens (${pct(obsProgress, runtime.config.observeAfterTokens)}%)`,
81
84
  `Next reflection: ~${reflectionProgress.toLocaleString()} / ${runtime.config.reflectAfterTokens.toLocaleString()} tokens (${pct(reflectionProgress, runtime.config.reflectAfterTokens)}%)`,
82
- `Next compaction: ~${compactionProgress.toLocaleString()} / ${runtime.config.compactAfterTokens.toLocaleString()} tokens (${pct(compactionProgress, runtime.config.compactAfterTokens)}%)`,
85
+ `Next compaction: ~${compactionProgress.toLocaleString()} / ${compactThreshold.toLocaleString()} estimated source tokens (${pct(compactionProgress, compactThreshold)}%)`,
83
86
  `Visible observation pool: ~${visibleObservationTokens.toLocaleString()} / ${runtime.config.observationsPoolMaxTokens.toLocaleString()} tokens (${pct(visibleObservationTokens, runtime.config.observationsPoolMaxTokens)}%)`,
84
87
  `Active observation pool: ~${activeObservationPool.observationTokens.toLocaleString()} / ${runtime.config.observationsPoolTargetTokens.toLocaleString()} target tokens (${pct(activeObservationPool.observationTokens, runtime.config.observationsPoolTargetTokens)}%)`,
85
88
  `Reflection pool: ~${visibleReflectionTokens.toLocaleString()} tokens`,
package/src/config.ts CHANGED
@@ -9,14 +9,44 @@ export interface ConfiguredModel {
9
9
  thinking?: ModelThinkingLevel;
10
10
  }
11
11
 
12
+ /**
13
+ * How `compactAfterTokens` is interpreted.
14
+ *
15
+ * - `"calibrated"` (default): use the static `compactAfterTokens` value directly.
16
+ * Backwards-compatible with all existing V3 configs.
17
+ *
18
+ * - `"ratio"`: compute the effective threshold as
19
+ * `floor(model.contextWindow * compactAfterTokensRatio)`. This auto-scales the
20
+ * proactive compaction trigger to the active model's context window, so a 1M
21
+ * context model is not preempted at the same 81K threshold as a 128K model.
22
+ *
23
+ * Some models advertise a large context window but lose attention at long
24
+ * range; users can lower `compactAfterTokensRatio` to compact earlier on such
25
+ * models without giving up the window on models that stay sharp.
26
+ *
27
+ * When the active model's `contextWindow` is unavailable (undefined, 0, or
28
+ * negative), ratio mode falls back to the calibrated `compactAfterTokens`
29
+ * value so compaction still triggers safely.
30
+ */
31
+ export type CompactAfterTokensMode = "calibrated" | "ratio";
32
+
12
33
  export interface Config {
13
34
  observeAfterTokens: number;
14
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;
15
42
  compactAfterTokens: number;
43
+ compactAfterTokensMode: CompactAfterTokensMode;
44
+ compactAfterTokensRatio: number;
16
45
  observationsPoolMaxTokens: number;
17
46
  observationsPoolTargetTokens: number;
18
47
  agentMaxTurns: number;
19
48
  model?: ConfiguredModel;
49
+ showWorkerNotifications: boolean;
20
50
  passive: boolean;
21
51
  debugLog: boolean;
22
52
  }
@@ -25,14 +55,78 @@ export const DEFAULTS: Config = {
25
55
  observeAfterTokens: 10_000,
26
56
  reflectAfterTokens: 20_000,
27
57
  compactAfterTokens: 81_000,
58
+ compactAfterTokensMode: "calibrated",
59
+ compactAfterTokensRatio: 0.68,
28
60
  observationsPoolMaxTokens: 20_000,
29
61
  observationsPoolTargetTokens: 10_000,
30
62
  agentMaxTurns: 16,
63
+ showWorkerNotifications: true,
31
64
  passive: false,
32
65
  debugLog: false,
33
66
  };
34
67
 
35
- export const THINKING_LEVEL_VALUES: readonly ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
68
+ export const COMPACT_AFTER_TOKENS_MODE_VALUES: readonly CompactAfterTokensMode[] = ["calibrated", "ratio"] as const;
69
+
70
+ /**
71
+ * Resolve the effective proactive-compaction token threshold for the given
72
+ * config and active model context window.
73
+ *
74
+ * In `"calibrated"` mode this is always `config.compactAfterTokens`.
75
+ *
76
+ * In `"ratio"` mode this is `floor(contextWindow * compactAfterTokensRatio)`
77
+ * (clamped to a minimum of 1) when `contextWindow` is a positive number, and
78
+ * falls back to `config.compactAfterTokens` otherwise.
79
+ */
80
+ export function resolveCompactAfterTokens(config: Config, contextWindow: number | undefined): number {
81
+ if (config.compactAfterTokensMode === "ratio" && typeof contextWindow === "number" && contextWindow > 0) {
82
+ return Math.max(1, Math.floor(contextWindow * config.compactAfterTokensRatio));
83
+ }
84
+ return config.compactAfterTokens;
85
+ }
86
+
87
+ export const THINKING_LEVEL_VALUES: readonly ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
88
+
89
+ /** Observer chunk cap used when no config is set and the model's context window is unknown. */
90
+ export const OBSERVER_CHUNK_FALLBACK_MAX_TOKENS = 60_000;
91
+
92
+ /** Smallest useful observer chunk: enough for labels, omission markers, and source context. */
93
+ export const OBSERVER_CHUNK_MIN_TOKENS = 256;
94
+
95
+ /**
96
+ * Fraction of the memory model's context window used for the derived observer
97
+ * chunk cap. Chunk sizes are estimated at ~4 chars/token, which can undercount
98
+ * real tokens by up to ~4x on non-ASCII content, so 0.2 keeps even the worst
99
+ * case at ~80% of the window with room left for the system prompt, prior
100
+ * memory, and the response.
101
+ */
102
+ export const OBSERVER_CHUNK_CONTEXT_RATIO = 0.2;
103
+
104
+ /**
105
+ * Resolve the maximum estimated tokens the observer serializes into one chunk.
106
+ *
107
+ * An explicit `observerChunkMaxTokens` config value always wins. Otherwise the
108
+ * cap is `floor(contextWindow * OBSERVER_CHUNK_CONTEXT_RATIO)` for the resolved
109
+ * memory model, falling back to {@link OBSERVER_CHUNK_FALLBACK_MAX_TOKENS} when
110
+ * the context window is unavailable.
111
+ *
112
+ * Without a cap, a backlog that outgrows the model's context window (e.g.
113
+ * after repeated observer failures, or when the extension is enabled mid-way
114
+ * into a long session) makes every observer call fail, so coverage never
115
+ * advances and the session can never recover. With the cap, oversized backlogs
116
+ * are drained oldest-first across successive runs.
117
+ */
118
+ export function resolveObserverChunkMaxTokens(config: Config, contextWindow: number | undefined): number {
119
+ if (config.observerChunkMaxTokens !== undefined && config.observerChunkMaxTokens > 0) {
120
+ return Math.max(OBSERVER_CHUNK_MIN_TOKENS, config.observerChunkMaxTokens);
121
+ }
122
+ if (typeof contextWindow === "number" && Number.isFinite(contextWindow) && contextWindow > 0) {
123
+ return Math.max(
124
+ OBSERVER_CHUNK_MIN_TOKENS,
125
+ Math.floor(contextWindow * OBSERVER_CHUNK_CONTEXT_RATIO),
126
+ );
127
+ }
128
+ return OBSERVER_CHUNK_FALLBACK_MAX_TOKENS;
129
+ }
36
130
 
37
131
  const SETTINGS_KEY = "observational-memory";
38
132
  const PASSIVE_ENV = "PI_OBSERVATIONAL_MEMORY_PASSIVE";
@@ -54,6 +148,19 @@ function isThinkingLevel(value: unknown): value is ModelThinkingLevel {
54
148
  return typeof value === "string" && (THINKING_LEVEL_VALUES as readonly string[]).includes(value);
55
149
  }
56
150
 
151
+ function isCompactAfterTokensMode(value: unknown): value is CompactAfterTokensMode {
152
+ return typeof value === "string" && (COMPACT_AFTER_TOKENS_MODE_VALUES as readonly string[]).includes(value);
153
+ }
154
+
155
+ /**
156
+ * A valid ratio is a finite number strictly between 0 and 1.
157
+ * 0 would never trigger; >= 1 would compact at/after the full window with no
158
+ * room left for the response.
159
+ */
160
+ function validRatioOrUndefined(value: unknown): number | undefined {
161
+ return typeof value === "number" && Number.isFinite(value) && value > 0 && value < 1 ? value : undefined;
162
+ }
163
+
57
164
  function isRecord(value: unknown): value is Record<string, unknown> {
58
165
  return typeof value === "object" && value !== null;
59
166
  }
@@ -77,6 +184,7 @@ function normalizeSettingsConfig(value: Record<string, unknown>): Partial<Config
77
184
  const numberKeys = [
78
185
  "observeAfterTokens",
79
186
  "reflectAfterTokens",
187
+ "observerChunkMaxTokens",
80
188
  "compactAfterTokens",
81
189
  "observationsPoolMaxTokens",
82
190
  "observationsPoolTargetTokens",
@@ -86,6 +194,12 @@ function normalizeSettingsConfig(value: Record<string, unknown>): Partial<Config
86
194
  const normalizedValue = positiveIntegerOrUndefined(value[key]);
87
195
  if (normalizedValue !== undefined) normalized[key] = normalizedValue;
88
196
  }
197
+ if (isCompactAfterTokensMode(value.compactAfterTokensMode)) {
198
+ normalized.compactAfterTokensMode = value.compactAfterTokensMode;
199
+ }
200
+ const ratio = validRatioOrUndefined(value.compactAfterTokensRatio);
201
+ if (ratio !== undefined) normalized.compactAfterTokensRatio = ratio;
202
+ if (typeof value.showWorkerNotifications === "boolean") normalized.showWorkerNotifications = value.showWorkerNotifications;
89
203
  if (typeof value.passive === "boolean") normalized.passive = value.passive;
90
204
  if (typeof value.debugLog === "boolean") normalized.debugLog = value.debugLog;
91
205
  const model = normalizeModel(value.model);
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { resolveCompactAfterTokens } from "../config.js";
2
3
  import { rawTokensSinceLastCompaction, type Entry } from "../session-ledger/index.js";
3
4
  import type { Runtime } from "../runtime.js";
4
5
 
@@ -31,9 +32,12 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
31
32
  return;
32
33
  }
33
34
 
34
- const entries = ctx.sessionManager.getBranch() as Entry[];
35
- const tokens = rawTokensSinceLastCompaction(entries);
36
- if (tokens < runtime.config.compactAfterTokens) return;
35
+ const entries = ctx.sessionManager?.getBranch?.() as Entry[] | undefined;
36
+ if (!entries) return;
37
+ const progress = rawTokensSinceLastCompaction(entries);
38
+ const contextWindow = typeof ctx.model?.contextWindow === "number" ? ctx.model.contextWindow : undefined;
39
+ const threshold = resolveCompactAfterTokens(runtime.config, contextWindow);
40
+ if (progress < threshold) return;
37
41
 
38
42
  // Capture ctx properties synchronously — the setTimeout + async work below
39
43
  // may outlive the extension ctx (stale after session replacement/reload).
@@ -41,7 +45,7 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
41
45
  const ui = ctx.ui;
42
46
 
43
47
  if (hasUI) ui?.notify(
44
- `Observational memory: compaction threshold reached (~${tokens.toLocaleString()} tokens); triggering compaction`,
48
+ `Observational memory: compaction threshold reached (~${progress.toLocaleString()} estimated source tokens); triggering compaction`,
45
49
  "info",
46
50
  );
47
51
 
@@ -56,9 +60,13 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
56
60
  );
57
61
  return;
58
62
  }
59
- const currentEntries = ctx.sessionManager.getBranch() as Entry[];
60
- const currentTokens = rawTokensSinceLastCompaction(currentEntries);
61
- if (currentTokens < runtime.config.compactAfterTokens) {
63
+ const currentEntries = ctx.sessionManager?.getBranch?.() as Entry[] | undefined;
64
+ if (!currentEntries) {
65
+ runtime.compactInFlight = false;
66
+ return;
67
+ }
68
+ const currentProgress = rawTokensSinceLastCompaction(currentEntries);
69
+ if (currentProgress < threshold) {
62
70
  runtime.compactInFlight = false;
63
71
  if (hasUI) ui?.notify(
64
72
  "Observational memory: compaction skipped — another compaction already ran before deferred compaction",
@@ -1,10 +1,11 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { runDropper } from "../agents/dropper/agent.js";
3
3
  import { observationPoolMetrics } from "../agents/dropper/pool.js";
4
- import { runObserver } from "../agents/observer/agent.js";
4
+ import { ObserverStreamError, runObserver } from "../agents/observer/agent.js";
5
5
  import { runReflector } from "../agents/reflector/agent.js";
6
6
  import { debugLog, withDebugLogContext } from "../debug-log.js";
7
- import { type ResolveResult, type Runtime } from "../runtime.js";
7
+ import { resolveObserverChunkMaxTokens } from "../config.js";
8
+ import type { ResolveResult, Runtime } from "../runtime.js";
8
9
  import { serializeSourceAddressedBranchEntries } from "../serialize.js";
9
10
  import {
10
11
  OM_OBSERVATIONS_DROPPED,
@@ -20,11 +21,14 @@ import {
20
21
  latestCoverageIndex,
21
22
  latestCoverageMarkerId,
22
23
  observationToSummaryLine,
24
+ realTokensSinceAnchor,
23
25
  rawTokensSinceObservationCoverage,
24
26
  rawTokensSinceReflectionCoverage,
25
27
  reflectionToSummaryLine,
26
28
  type Entry,
29
+ type Observation,
27
30
  type Reflection,
31
+ type V3MemoryCustomType,
28
32
  } from "../session-ledger/index.js";
29
33
 
30
34
  type ResolvedModel = Extract<ResolveResult, { ok: true }>;
@@ -35,6 +39,7 @@ type ConsolidationCtx = {
35
39
  ui?: { notify: (message: string, type?: "warning" | "info" | "error") => void };
36
40
  model: unknown;
37
41
  modelRegistry: any;
42
+ getContextUsage?: () => { tokens?: number | null; contextWindow?: number } | undefined;
38
43
  sessionManager: {
39
44
  getBranch: () => unknown;
40
45
  getSessionId?: () => string;
@@ -69,9 +74,43 @@ function mergeReflections(existing: Reflection[], additional: Reflection[]): Ref
69
74
  return merged;
70
75
  }
71
76
 
72
- function anyStageDue(entries: Entry[], runtime: Runtime): boolean {
73
- return rawTokensSinceObservationCoverage(entries) >= runtime.config.observeAfterTokens
74
- || rawTokensSinceReflectionCoverage(entries) >= runtime.config.reflectAfterTokens;
77
+ /**
78
+ * Real current context tokens from the session (provider-reported usage, the
79
+ * same basis the footer percentage uses). Falls back to undefined when the
80
+ * host pi lacks getContextUsage or the count is unknown (e.g. right after a
81
+ * compaction, before the next valid assistant response).
82
+ */
83
+ function realContextTokens(ctx: ConsolidationCtx): number | undefined {
84
+ const usage = typeof ctx.getContextUsage === "function" ? ctx.getContextUsage() : undefined;
85
+ const tokens = usage?.tokens;
86
+ return typeof tokens === "number" && Number.isFinite(tokens) ? tokens : undefined;
87
+ }
88
+
89
+ function stageDue(
90
+ entries: Entry[],
91
+ runtime: Runtime,
92
+ currentTokens: number | undefined,
93
+ customType: V3MemoryCustomType,
94
+ rawEstimateFn: (entries: Entry[]) => number,
95
+ threshold: number,
96
+ ): boolean {
97
+ if (currentTokens !== undefined) {
98
+ const real = realTokensSinceAnchor(entries, customType, currentTokens);
99
+ if (real !== undefined) return real >= threshold;
100
+ }
101
+ // Real delta unmeasurable (no usage baseline, or accounting basis changed) or
102
+ // old pi host without getContextUsage — fall back to the raw estimate, which
103
+ // self-limits after coverage and cannot over-fire or starve.
104
+ return rawEstimateFn(entries) >= threshold;
105
+ }
106
+
107
+ function anyStageDue(entries: Entry[], runtime: Runtime, currentTokens: number | undefined): boolean {
108
+ return stageDue(entries, runtime, currentTokens, OM_OBSERVATIONS_RECORDED, rawTokensSinceObservationCoverage, runtime.config.observeAfterTokens)
109
+ || stageDue(entries, runtime, currentTokens, OM_REFLECTIONS_RECORDED, rawTokensSinceReflectionCoverage, runtime.config.reflectAfterTokens);
110
+ }
111
+
112
+ function shouldNotifyWorker(runtime: Runtime, ctx: ConsolidationCtx): boolean {
113
+ return runtime.config.showWorkerNotifications && ctx.hasUI;
75
114
  }
76
115
 
77
116
  function makeModelResolver(runtime: Runtime, ctx: ConsolidationCtx): (stage: "observer" | "reflector" | "dropper") => Promise<ResolvedModel | undefined> {
@@ -121,7 +160,7 @@ function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: Conso
121
160
  if (runtime.consolidationInFlight) return;
122
161
 
123
162
  const entries = ctx.sessionManager.getBranch() as Entry[];
124
- if (!anyStageDue(entries, runtime)) return;
163
+ if (!anyStageDue(entries, runtime, realContextTokens(ctx))) return;
125
164
 
126
165
  const runId = `consolidation-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
127
166
  const consolidationCtx: ConsolidationCtx = {
@@ -130,6 +169,7 @@ function maybeLaunchConsolidation(pi: ExtensionAPI, runtime: Runtime, ctx: Conso
130
169
  ui: ctx.ui,
131
170
  model: ctx.model,
132
171
  modelRegistry: ctx.modelRegistry,
172
+ getContextUsage: ctx.getContextUsage,
133
173
  sessionManager: ctx.sessionManager,
134
174
  };
135
175
 
@@ -185,27 +225,79 @@ async function runObserverStage(
185
225
  resolveModel: (stage: "observer") => Promise<ResolvedModel | undefined>,
186
226
  ): Promise<StageOutcome> {
187
227
  const entries = ctx.sessionManager.getBranch() as Entry[];
188
- const tokens = rawTokensSinceObservationCoverage(entries);
228
+ const currentTokens = realContextTokens(ctx);
229
+ const real = currentTokens !== undefined ? realTokensSinceAnchor(entries, OM_OBSERVATIONS_RECORDED, currentTokens) : undefined;
230
+ const tokens = real !== undefined ? real : rawTokensSinceObservationCoverage(entries); // fallback: no usage baseline / basis change
189
231
  if (tokens < runtime.config.observeAfterTokens) return "continue";
190
232
 
233
+ const sessionMetadata = debugSessionMetadata(ctx);
234
+ const sessionIdentity = sessionMetadata.sessionId ?? sessionMetadata.sessionFile;
235
+ const coverageId = latestCoverageMarkerId(entries, OM_OBSERVATIONS_RECORDED);
236
+
237
+ // Deliberate-empty backoff (#23): an intentional "nothing to record" verdict
238
+ // must not re-fire the observer every turn over the same span. Retry only
239
+ // after another observeAfterTokens worth of new source tokens arrives, and
240
+ // drop the backoff as soon as coverage advances.
241
+ const backoff = runtime.observerEmptyBackoff;
242
+ if (backoff) {
243
+ if (
244
+ sessionIdentity !== backoff.sessionIdentity
245
+ || coverageId !== backoff.coverageId
246
+ || tokens >= backoff.tokensAtEmpty + runtime.config.observeAfterTokens
247
+ ) {
248
+ runtime.observerEmptyBackoff = undefined;
249
+ } else {
250
+ debugLog("observer.empty_backoff", { tokens, resumeAtTokens: backoff.tokensAtEmpty + runtime.config.observeAfterTokens });
251
+ return "continue";
252
+ }
253
+ }
254
+
255
+ // Resolve the model before building the chunk: the default chunk cap
256
+ // derives from the resolved model's context window.
257
+ const resolved = await resolveModel("observer");
258
+ if (!resolved) return "abort";
259
+
191
260
  const lastCoverageIdx = latestCoverageIndex(entries, OM_OBSERVATIONS_RECORDED);
192
- const chunkEntries = sourceEntriesAfter(entries, lastCoverageIdx);
193
- const coversUpToId = chunkEntries.at(-1)?.id;
261
+ const backlogEntries = sourceEntriesAfter(entries, lastCoverageIdx);
262
+
263
+ // Budget the text that is actually sent to the observer, including source
264
+ // labels and rendered message content. Complete entries are kept intact.
265
+ // Only a first entry that cannot fit by itself is represented by a clearly
266
+ // marked head/tail excerpt; the original ledger entry remains untouched.
267
+ const contextWindow = (resolved.model as { contextWindow?: number }).contextWindow;
268
+ const maxChunkTokens = resolveObserverChunkMaxTokens(runtime.config, contextWindow);
269
+ const {
270
+ text: chunk,
271
+ sourceEntryIds,
272
+ estimatedTokens: chunkTokens,
273
+ truncatedSourceEntryIds,
274
+ } = serializeSourceAddressedBranchEntries(backlogEntries, { maxTokens: maxChunkTokens });
275
+ if (!chunk.trim() || sourceEntryIds.length === 0) return "continue";
276
+ const coversUpToId = sourceEntryIds.at(-1);
194
277
  if (!coversUpToId) return "continue";
195
278
 
196
- const { text: chunk, sourceEntryIds } = serializeSourceAddressedBranchEntries(chunkEntries);
197
- if (!chunk.trim() || sourceEntryIds.length === 0) return "continue";
279
+ if (sourceEntryIds.length < backlogEntries.length || truncatedSourceEntryIds.length > 0) {
280
+ debugLog("observer.chunk_capped", {
281
+ maxChunkTokens,
282
+ backlogEntries: backlogEntries.length,
283
+ backlogTokens: tokens,
284
+ chunkEntries: sourceEntryIds.length,
285
+ chunkTokens,
286
+ truncatedSourceEntryIds,
287
+ });
288
+ }
198
289
 
199
290
  const memory = fullProjection(entries);
200
291
  const priorReflections = memory.reflections.map(reflectionToSummaryLine);
201
292
  const priorObservations = memory.observations.map(observationToSummaryLine);
202
293
 
203
- if (ctx.hasUI) ctx.ui?.notify(
204
- `Observational memory: observer running on ~${tokens.toLocaleString()}-token chunk`,
294
+ if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
295
+ `Observational memory: observer running on ~${chunkTokens.toLocaleString()}-token chunk`,
205
296
  "info",
206
297
  );
207
298
  debugLog("observer.start", {
208
299
  tokens,
300
+ chunkTokens,
209
301
  coversUpToId,
210
302
  sourceEntryIds,
211
303
  sourceEntryCount: sourceEntryIds.length,
@@ -213,28 +305,40 @@ async function runObserverStage(
213
305
  priorObservations: priorObservations.length,
214
306
  });
215
307
 
216
- const resolved = await resolveModel("observer");
217
- if (!resolved) return "abort";
218
-
219
- const observations = await runObserver({
220
- model: resolved.model as any,
221
- apiKey: resolved.apiKey,
222
- headers: resolved.headers,
223
- priorReflections,
224
- priorObservations,
225
- chunk,
226
- allowedSourceEntryIds: sourceEntryIds,
227
- maxTurns: runtime.config.agentMaxTurns,
228
- thinkingLevel: runtime.config.model?.thinking ?? "low",
229
- });
308
+ let observations: Observation[] | undefined;
309
+ try {
310
+ observations = await runObserver({
311
+ model: resolved.model as any,
312
+ apiKey: resolved.apiKey,
313
+ headers: resolved.headers,
314
+ priorReflections,
315
+ priorObservations,
316
+ chunk,
317
+ allowedSourceEntryIds: sourceEntryIds,
318
+ maxTurns: runtime.config.agentMaxTurns,
319
+ thinkingLevel: runtime.config.model?.thinking ?? "low",
320
+ });
321
+ } catch (error) {
322
+ if (error instanceof ObserverStreamError) {
323
+ // API/stream failure is not a clean empty (#32): surface it as a real
324
+ // failure instead of the "no observations" path. Coverage stays put.
325
+ runtime.recordConsolidationStageError(ctx, "observer", error);
326
+ return "abort";
327
+ }
328
+ throw error;
329
+ }
230
330
  if (!observations || observations.length === 0) {
331
+ // Deliberate empty: routine info, not a warning, and back off re-fires
332
+ // over the same span (#23).
231
333
  debugLog("observer.empty", { coversUpToId });
232
- if (ctx.hasUI) ctx.ui?.notify(
233
- "Observational memory: observer returned no observations",
234
- "warning",
334
+ runtime.observerEmptyBackoff = { sessionIdentity, coverageId, tokensAtEmpty: tokens };
335
+ if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
336
+ "Observational memory: observer found nothing new in this chunk (coverage unchanged; will retry later)",
337
+ "info",
235
338
  );
236
339
  return "continue";
237
340
  }
341
+ runtime.observerEmptyBackoff = undefined;
238
342
 
239
343
  const data = buildObservationsRecordedData(observations, coversUpToId);
240
344
  if (!data) return "continue";
@@ -245,7 +349,7 @@ async function runObserverStage(
245
349
  });
246
350
  appendEntry(pi, OM_OBSERVATIONS_RECORDED, data);
247
351
  debugLog("observer.appended", { count: observations.length, coversUpToId });
248
- if (ctx.hasUI) ctx.ui?.notify(
352
+ if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
249
353
  `Observational memory: ${observations.length} observation${observations.length === 1 ? "" : "s"} recorded`,
250
354
  "info",
251
355
  );
@@ -259,13 +363,15 @@ async function runReflectorStage(
259
363
  resolveModel: (stage: "reflector") => Promise<ResolvedModel | undefined>,
260
364
  ): Promise<ReflectorStageResult> {
261
365
  const entries = ctx.sessionManager.getBranch() as Entry[];
262
- const reflectionTokens = rawTokensSinceReflectionCoverage(entries);
366
+ const currentTokens = realContextTokens(ctx);
367
+ const real = currentTokens !== undefined ? realTokensSinceAnchor(entries, OM_REFLECTIONS_RECORDED, currentTokens) : undefined;
368
+ const reflectionTokens = real !== undefined ? real : rawTokensSinceReflectionCoverage(entries); // fallback: no usage baseline / basis change
263
369
  if (reflectionTokens < runtime.config.reflectAfterTokens) return { outcome: "continue", sameRunReflections: [] };
264
370
 
265
371
  const observationCoverageId = latestCoverageMarkerId(entries, OM_OBSERVATIONS_RECORDED);
266
372
  if (!observationCoverageId) return { outcome: "continue", sameRunReflections: [] };
267
373
 
268
- if (ctx.hasUI) ctx.ui?.notify(
374
+ if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
269
375
  `Observational memory: reflector running (~${reflectionTokens.toLocaleString()} tokens)`,
270
376
  "info",
271
377
  );
@@ -337,7 +443,7 @@ async function runDropperStage(
337
443
  maxDropsAllowed: metrics.maxDropsAllowed,
338
444
  });
339
445
 
340
- if (ctx.hasUI) ctx.ui?.notify(
446
+ if (shouldNotifyWorker(runtime, ctx)) ctx.ui?.notify(
341
447
  `Observational memory: dropper running after reflection — active observation pool ~${metrics.observationTokens.toLocaleString()} / ${metrics.targetTokens.toLocaleString()} target tokens (${Math.round(metrics.fullness * 100).toLocaleString()}%)`,
342
448
  "info",
343
449
  );
package/src/runtime.ts CHANGED
@@ -1,9 +1,26 @@
1
1
  import { type Config, DEFAULTS, loadConfig } from "./config.js";
2
2
 
3
3
  export type ResolveResult =
4
- | { ok: true; model: unknown; apiKey: string; headers?: Record<string, string> }
4
+ | { ok: true; model: unknown; apiKey?: string; headers?: Record<string, string> }
5
5
  | { ok: false; reason: string };
6
6
 
7
+ /**
8
+ * Mirrors pi's own request-auth acceptance rule (`AgentSession._getRequiredRequestAuth`):
9
+ * resolved auth is usable when it carries an apiKey OR at least one header value.
10
+ * OAuth providers (kimi-coding, xai, openai-codex, anthropic OAuth, …) authenticate via
11
+ * `toAuth()` returning `{ headers: { Authorization: "Bearer …" } }` with no apiKey, and
12
+ * pi-ai providers accept a caller-supplied Authorization header in place of an apiKey.
13
+ */
14
+ function hasUsableAuth(auth: { apiKey?: unknown; headers?: unknown }): boolean {
15
+ 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
+ }
23
+
7
24
  type NotifyLevel = "warning" | "info" | "error";
8
25
  type Notify = (message: string, type?: NotifyLevel) => void;
9
26
  export type ConsolidationPhase = "observer" | "reflector" | "dropper";
@@ -32,6 +49,12 @@ export class Runtime {
32
49
  lastObserverError: string | undefined;
33
50
  lastReflectorError: string | undefined;
34
51
  lastDropperError: string | undefined;
52
+ /** Deliberate-empty backoff (#23): skip observer re-fires over the same span until enough new tokens arrive. */
53
+ observerEmptyBackoff: {
54
+ sessionIdentity: string | undefined;
55
+ coverageId: string | undefined;
56
+ tokensAtEmpty: number;
57
+ } | undefined;
35
58
 
36
59
  ensureConfig(cwd: string): void {
37
60
  if (this.configLoaded) return;
@@ -54,11 +77,15 @@ export class Runtime {
54
77
  }
55
78
  if (!model) return { ok: false, reason: "no model available (session has no model and no observational-memory model configured)" };
56
79
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
57
- if (!auth.ok || !auth.apiKey) {
58
- const provider = (model as { provider?: string }).provider ?? "unknown";
59
- return { ok: false, reason: `no API key for provider "${provider}"` };
80
+ const provider = (model as { provider?: string }).provider ?? "unknown";
81
+ if (!auth.ok || !hasUsableAuth(auth)) {
82
+ const isOAuth = ctx.modelRegistry.isUsingOAuth?.(model) === true;
83
+ const reason = isOAuth
84
+ ? `authentication failed for provider "${provider}" — OAuth credentials may have expired; run '/login ${provider}' to re-authenticate`
85
+ : `no API key or auth headers for provider "${provider}"`;
86
+ return { ok: false, reason };
60
87
  }
61
- return { ok: true, model, apiKey: auth.apiKey as string, headers: auth.headers as Record<string, string> | undefined };
88
+ return { ok: true, model, apiKey: auth.apiKey as string | undefined, headers: auth.headers as Record<string, string> | undefined };
62
89
  }
63
90
 
64
91
  launchConsolidationTask(ctx: LaunchCtx, work: () => Promise<void>): Promise<void> {
package/src/serialize.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Message, TextContent, ToolResultMessage } from "@earendil-works/pi-ai";
2
+ import { estimateStringTokens } from "./tokens.js";
2
3
 
3
4
  function pad(n: number): string {
4
5
  return n.toString().padStart(2, "0");
@@ -160,23 +161,78 @@ export function serializeBranchEntries(entries: RenderableEntry[]): string {
160
161
  export type SourceAddressedSerialization = {
161
162
  text: string;
162
163
  sourceEntryIds: string[];
164
+ estimatedTokens: number;
165
+ truncatedSourceEntryIds: string[];
163
166
  };
164
167
 
168
+ export type SourceAddressedSerializationOptions = {
169
+ /** Maximum estimated tokens in the final source-addressed text. */
170
+ maxTokens?: number;
171
+ };
172
+
173
+ const SOURCE_OMISSION_MARKER =
174
+ "\n\n[… middle omitted: source exceeds observer input budget; original source remains in the session ledger …]\n\n";
175
+
176
+ function truncateSourceBlockToTokenBudget(label: string, rendered: string, maxTokens: number): string | undefined {
177
+ const required = `${label}\n${SOURCE_OMISSION_MARKER}`;
178
+ if (estimateStringTokens(required) > maxTokens) return undefined;
179
+ const full = `${label}\n${rendered}`;
180
+ if (estimateStringTokens(full) <= maxTokens) return full;
181
+ const maxChars = Math.max(1, maxTokens * 4);
182
+ const fixed = `${label}\n${SOURCE_OMISSION_MARKER}`;
183
+ const retainedChars = maxChars - fixed.length;
184
+ const headChars = Math.ceil(retainedChars / 2);
185
+ const tailChars = retainedChars - headChars;
186
+ return `${label}\n${rendered.slice(0, headChars)}${SOURCE_OMISSION_MARKER}${tailChars > 0 ? rendered.slice(-tailChars) : ""}`;
187
+ }
188
+
165
189
  function isSourceRenderableEntry(entry: RenderableEntry): boolean {
166
190
  return entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary";
167
191
  }
168
192
 
169
- export function serializeSourceAddressedBranchEntries(entries: RenderableEntry[]): SourceAddressedSerialization {
193
+ /**
194
+ * Serialize complete source entries up to the token budget. If the first entry
195
+ * alone exceeds the budget, include a clearly marked head/tail excerpt so one
196
+ * pathological tool result cannot permanently block observation coverage.
197
+ * The original ledger entry is never modified and remains recallable by id.
198
+ */
199
+ export function serializeSourceAddressedBranchEntries(
200
+ entries: RenderableEntry[],
201
+ options: SourceAddressedSerializationOptions = {},
202
+ ): SourceAddressedSerialization {
170
203
  const blocks: string[] = [];
171
204
  const sourceEntryIds: string[] = [];
205
+ const truncatedSourceEntryIds: string[] = [];
206
+ let estimatedTokens = 0;
207
+
172
208
  for (const entry of entries) {
173
209
  if (!entry.id || !isSourceRenderableEntry(entry)) continue;
174
210
  const rendered = serializeBranchEntries([entry]);
175
211
  if (!rendered.trim()) continue;
212
+ const label = `[Source entry id: ${entry.id}]`;
213
+ const block = `${label}\n${rendered}`;
214
+ const separator = blocks.length > 0 ? "\n\n" : "";
215
+ const blockTokens = estimateStringTokens(`${separator}${block}`);
216
+ const maxTokens = options.maxTokens;
217
+
218
+ if (maxTokens !== undefined && estimatedTokens + blockTokens > maxTokens) {
219
+ if (blocks.length > 0) break;
220
+ const excerpt = truncateSourceBlockToTokenBudget(label, rendered, maxTokens);
221
+ if (!excerpt) break;
222
+ blocks.push(excerpt);
223
+ sourceEntryIds.push(entry.id);
224
+ truncatedSourceEntryIds.push(entry.id);
225
+ estimatedTokens = estimateStringTokens(excerpt);
226
+ break;
227
+ }
228
+
229
+ blocks.push(block);
176
230
  sourceEntryIds.push(entry.id);
177
- blocks.push(`[Source entry id: ${entry.id}]\n${rendered}`);
231
+ estimatedTokens += blockTokens;
178
232
  }
179
- return { text: blocks.join("\n\n"), sourceEntryIds };
233
+
234
+ const text = blocks.join("\n\n");
235
+ return { text, sourceEntryIds, estimatedTokens: estimateStringTokens(text), truncatedSourceEntryIds };
180
236
  }
181
237
 
182
238
  function renderRecallMessage(entry: RenderableEntry): string | null {
@@ -117,6 +117,106 @@ export function findLastCompactionIndex(entries: Entry[]): number {
117
117
  return -1;
118
118
  }
119
119
 
120
+ // ==== Real (provider-reported) token accounting ====
121
+ //
122
+ // These helpers measure context growth from provider-reported usage for the
123
+ // observation and reflection coverage clocks. Automatic compaction keeps its
124
+ // separate raw source-entry clock because its setting counts ledger entries.
125
+
126
+ type UsageLike = {
127
+ totalTokens?: number;
128
+ input?: number;
129
+ output?: number;
130
+ cacheRead?: number;
131
+ cacheWrite?: number;
132
+ };
133
+
134
+ export function contextTokensFromUsage(usage: unknown): number | undefined {
135
+ if (!usage || typeof usage !== "object") return undefined;
136
+ const u = usage as UsageLike;
137
+ const total = typeof u.totalTokens === "number" && Number.isFinite(u.totalTokens) && u.totalTokens > 0 ? u.totalTokens : undefined;
138
+ if (total !== undefined) return total;
139
+ const parts = [u.input, u.output, u.cacheRead, u.cacheWrite];
140
+ if (parts.every((p) => typeof p === "number" && Number.isFinite(p))) {
141
+ const sum = parts.reduce<number>((acc, p) => acc + (p ?? 0), 0);
142
+ return sum > 0 ? sum : undefined;
143
+ }
144
+ return undefined;
145
+ }
146
+
147
+ function validAssistantContextTokens(entry: Entry): number | undefined {
148
+ if (entry.type !== "message" || !entry.message || typeof entry.message !== "object") return undefined;
149
+ const msg = entry.message as { role?: string; stopReason?: string; usage?: unknown };
150
+ if (msg.role !== "assistant" || msg.stopReason === "aborted" || msg.stopReason === "error") return undefined;
151
+ return contextTokensFromUsage(msg.usage);
152
+ }
153
+
154
+ /**
155
+ * Real context tokens right after a compaction anchor.
156
+ *
157
+ * Only usage from an assistant that responded AFTER the compaction is a valid
158
+ * post-compaction baseline: pi's own docs state the last assistant usage
159
+ * before/at a compaction reflects the PRE-compaction context size. The usage
160
+ * carried on the compaction entry itself is the summary-generation call's
161
+ * usage (pre-compaction scale, a different LLM call), so it is deliberately
162
+ * NOT used as a baseline.
163
+ */
164
+ export function realContextTokensAfterCompaction(entries: Entry[], compactionIdx: number): number | undefined {
165
+ for (let i = compactionIdx + 1; i < entries.length; i++) {
166
+ const t = validAssistantContextTokens(entries[i]);
167
+ if (t !== undefined) return t;
168
+ }
169
+ return undefined;
170
+ }
171
+
172
+ /**
173
+ * Real context tokens at the time observation coverage ended: last valid
174
+ * assistant usage at/before the covered entry. Returns undefined when no valid
175
+ * usage exists (e.g. an error/abort storm) — callers must fall back to the
176
+ * raw estimate rather than measuring from zero, which would otherwise read the
177
+ * full context as "growth" and re-fire stages every turn.
178
+ */
179
+ export function realContextTokensAtCoverage(entries: Entry[], coverageIdx: number): number | undefined {
180
+ for (let i = coverageIdx; i >= 0; i--) {
181
+ const t = validAssistantContextTokens(entries[i]);
182
+ if (t !== undefined) return t;
183
+ }
184
+ return undefined;
185
+ }
186
+
187
+ /**
188
+ * Real context growth since the most recent anchor (a compaction, or the given
189
+ * coverage marker), measured from provider-reported usage.
190
+ *
191
+ * Returns undefined when the baseline cannot be measured reliably — no usage
192
+ * at/after the anchor, or the current context is SMALLER than the baseline
193
+ * (accounting basis changed, e.g. a mid-session model/provider switch that
194
+ * counts usage differently). Callers must fall back to the raw estimate in
195
+ * that case; clamping a stale baseline to 0 would starve the stage forever,
196
+ * and measuring from zero would over-fire it.
197
+ */
198
+ export function realTokensSinceAnchor(
199
+ entries: Entry[],
200
+ customType: V3MemoryCustomType | undefined,
201
+ currentContextTokens: number,
202
+ ): number | undefined {
203
+ const coverageIdx = customType ? latestCoverageIndex(entries, customType) : -1;
204
+ const compactionIdx = findLastCompactionIndex(entries);
205
+ if (compactionIdx > coverageIdx) {
206
+ const baseline = realContextTokensAfterCompaction(entries, compactionIdx);
207
+ if (baseline === undefined) return undefined;
208
+ const delta = currentContextTokens - baseline;
209
+ return delta >= 0 ? delta : undefined;
210
+ }
211
+ if (coverageIdx >= 0) {
212
+ const baseline = realContextTokensAtCoverage(entries, coverageIdx);
213
+ if (baseline === undefined) return undefined;
214
+ const delta = currentContextTokens - baseline;
215
+ return delta >= 0 ? delta : undefined;
216
+ }
217
+ return Math.max(0, currentContextTokens);
218
+ }
219
+
120
220
  export function rawTokensSinceLastCompaction(entries: Entry[]): number {
121
221
  const compactionIndex = findLastCompactionIndex(entries);
122
222
  if (compactionIndex === -1) return rawTokensAfterIndex(entries, -1);
package/src/tokens.ts CHANGED
@@ -4,6 +4,24 @@ export function estimateStringTokens(text: string): number {
4
4
  return Math.ceil(text.length / 4);
5
5
  }
6
6
 
7
+ /**
8
+ * Estimate the rendered footprint of an observation line as it appears in
9
+ * summaries / pool listings: "[id] YYYY-MM-DD HH:MM [relevance] content".
10
+ * Pool budgets that only count bare content undercount every line's
11
+ * metadata overhead (id + timestamp + relevance tags), so the configured
12
+ * pool target was reached later than the rendered memory actually allowed.
13
+ */
14
+ export function observationLineTokenCount(observation: {
15
+ id: string;
16
+ timestamp: string;
17
+ relevance: string;
18
+ content: string;
19
+ }): number {
20
+ return estimateStringTokens(
21
+ `[${observation.id}] ${observation.timestamp} [${observation.relevance}] ${observation.content}`,
22
+ );
23
+ }
24
+
7
25
  export function estimateEntryTokens(entry: { type: string; message?: unknown; content?: unknown; summary?: unknown }): number {
8
26
  if (entry.type === "message" && entry.message) {
9
27
  return estimateMessageTokens(entry.message as Parameters<typeof estimateMessageTokens>[0]);