pi-observational-memory 3.0.3 → 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 +22 -7
- package/package.json +5 -5
- package/src/agents/dropper/agent.ts +6 -3
- package/src/agents/dropper/pool.ts +6 -2
- package/src/agents/observer/agent.ts +39 -6
- package/src/agents/reflector/agent.ts +6 -3
- package/src/agents/stream-errors.ts +22 -0
- package/src/commands/status.ts +1 -1
- package/src/config.ts +53 -1
- package/src/hooks/compaction-trigger.ts +12 -10
- package/src/hooks/consolidation-trigger.ts +140 -34
- package/src/runtime.ts +32 -5
- package/src/serialize.ts +59 -3
- package/src/session-ledger/progress.ts +100 -0
- package/src/tokens.ts +18 -0
package/README.md
CHANGED
|
@@ -218,6 +218,7 @@ A typical config:
|
|
|
218
218
|
"id": "google/gemma-4-31b-it",
|
|
219
219
|
"thinking": "low"
|
|
220
220
|
},
|
|
221
|
+
"showWorkerNotifications": true,
|
|
221
222
|
"passive": false,
|
|
222
223
|
"debugLog": false
|
|
223
224
|
}
|
|
@@ -229,9 +230,9 @@ Most users can start with the defaults and tune only if they have a specific rea
|
|
|
229
230
|
### Scaling compaction to the model's context window
|
|
230
231
|
|
|
231
232
|
By default `compactAfterTokensMode` is `"calibrated"`, so the proactive
|
|
232
|
-
compaction trigger
|
|
233
|
-
default).
|
|
234
|
-
context models.
|
|
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.
|
|
235
236
|
|
|
236
237
|
On a large-context model (e.g. 1M tokens) the calibrated default preempts
|
|
237
238
|
compaction at ~81K, wasting most of the window. Switch to `"ratio"` mode to let
|
|
@@ -249,8 +250,11 @@ the trigger scale with the active model's `contextWindow`:
|
|
|
249
250
|
|
|
250
251
|
In ratio mode the effective threshold is
|
|
251
252
|
`floor(model.contextWindow * compactAfterTokensRatio)` (clamped to a minimum of
|
|
252
|
-
1). With the example above, a 1,000,000-token window compacts
|
|
253
|
-
|
|
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.
|
|
254
258
|
|
|
255
259
|
`compactAfterTokensRatio` is user-tunable precisely because **context window ≠
|
|
256
260
|
attention**. Some models advertise a large window but degrade at long range; set
|
|
@@ -268,14 +272,16 @@ on the `Next compaction` line regardless of mode.
|
|
|
268
272
|
| Setting | Default | Meaning |
|
|
269
273
|
| --------------------------- | ------------- | ------------------------------------------------------------------------------------------------- |
|
|
270
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. |
|
|
271
276
|
| `reflectAfterTokens` | `20000` | Raw/source token threshold for reflection runs; successful reflection creates dropper opportunities. |
|
|
272
|
-
| `compactAfterTokens` | `81000` |
|
|
273
|
-
| `compactAfterTokensMode` | `"calibrated"`| `"calibrated"` uses `compactAfterTokens` directly
|
|
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`. |
|
|
274
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)`. |
|
|
275
280
|
| `observationsPoolMaxTokens` | `20000` | Observation-token budget used for compaction full-fold pressure. |
|
|
276
281
|
| `observationsPoolTargetTokens` | half of max | Active observation target used by post-reflection dropper maintenance. |
|
|
277
282
|
| `agentMaxTurns` | `16` | Shared turn cap for background memory-agent loops. |
|
|
278
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. |
|
|
279
285
|
| `passive` | `false` | Disables proactive background observation, reflection, maintenance, and auto-compaction triggers. |
|
|
280
286
|
| `debugLog` | `false` | Writes opt-in per-session extension debug events to Pi's agent directory. |
|
|
281
287
|
|
|
@@ -287,9 +293,12 @@ Valid `model.thinking` values are:
|
|
|
287
293
|
* `medium`
|
|
288
294
|
* `high`
|
|
289
295
|
* `xhigh`
|
|
296
|
+
* `max`
|
|
290
297
|
|
|
291
298
|
If no `model` is configured, memory workers use the session model.
|
|
292
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
|
+
|
|
293
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.
|
|
294
303
|
|
|
295
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.
|
|
@@ -340,6 +349,12 @@ The high-level lifecycle:
|
|
|
340
349
|
|
|
341
350
|
The important part: compaction does not need to rethink the whole session from scratch.
|
|
342
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
|
+
|
|
343
358
|
---
|
|
344
359
|
|
|
345
360
|
## Current V3 behavior
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-observational-memory",
|
|
3
|
-
"version": "3.0.
|
|
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.
|
|
44
|
-
"@earendil-works/pi-ai": "^0.
|
|
45
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
46
|
-
"@earendil-works/pi-tui": "^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
|
|
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
|
|
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
|
|
16
|
-
|
|
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 {
|
|
12
|
+
import { observationLineTokenCount } from "../../tokens.js";
|
|
11
13
|
|
|
12
14
|
interface RunObserverArgs {
|
|
13
15
|
model: Model<any>;
|
|
14
|
-
apiKey
|
|
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:
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
+
}
|
package/src/commands/status.ts
CHANGED
|
@@ -82,7 +82,7 @@ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
82
82
|
"── Activity ──",
|
|
83
83
|
`Next observation: ~${obsProgress.toLocaleString()} / ${runtime.config.observeAfterTokens.toLocaleString()} tokens (${pct(obsProgress, runtime.config.observeAfterTokens)}%)`,
|
|
84
84
|
`Next reflection: ~${reflectionProgress.toLocaleString()} / ${runtime.config.reflectAfterTokens.toLocaleString()} tokens (${pct(reflectionProgress, runtime.config.reflectAfterTokens)}%)`,
|
|
85
|
-
`Next compaction: ~${compactionProgress.toLocaleString()} / ${compactThreshold.toLocaleString()} tokens (${pct(compactionProgress, compactThreshold)}%)`,
|
|
85
|
+
`Next compaction: ~${compactionProgress.toLocaleString()} / ${compactThreshold.toLocaleString()} estimated source tokens (${pct(compactionProgress, compactThreshold)}%)`,
|
|
86
86
|
`Visible observation pool: ~${visibleObservationTokens.toLocaleString()} / ${runtime.config.observationsPoolMaxTokens.toLocaleString()} tokens (${pct(visibleObservationTokens, runtime.config.observationsPoolMaxTokens)}%)`,
|
|
87
87
|
`Active observation pool: ~${activeObservationPool.observationTokens.toLocaleString()} / ${runtime.config.observationsPoolTargetTokens.toLocaleString()} target tokens (${pct(activeObservationPool.observationTokens, runtime.config.observationsPoolTargetTokens)}%)`,
|
|
88
88
|
`Reflection pool: ~${visibleReflectionTokens.toLocaleString()} tokens`,
|
package/src/config.ts
CHANGED
|
@@ -33,6 +33,12 @@ export type CompactAfterTokensMode = "calibrated" | "ratio";
|
|
|
33
33
|
export interface Config {
|
|
34
34
|
observeAfterTokens: number;
|
|
35
35
|
reflectAfterTokens: number;
|
|
36
|
+
/**
|
|
37
|
+
* Maximum estimated source tokens serialized into a single observer chunk.
|
|
38
|
+
* Unset (default) derives the cap from the resolved memory model's context
|
|
39
|
+
* window; see {@link resolveObserverChunkMaxTokens}.
|
|
40
|
+
*/
|
|
41
|
+
observerChunkMaxTokens?: number;
|
|
36
42
|
compactAfterTokens: number;
|
|
37
43
|
compactAfterTokensMode: CompactAfterTokensMode;
|
|
38
44
|
compactAfterTokensRatio: number;
|
|
@@ -40,6 +46,7 @@ export interface Config {
|
|
|
40
46
|
observationsPoolTargetTokens: number;
|
|
41
47
|
agentMaxTurns: number;
|
|
42
48
|
model?: ConfiguredModel;
|
|
49
|
+
showWorkerNotifications: boolean;
|
|
43
50
|
passive: boolean;
|
|
44
51
|
debugLog: boolean;
|
|
45
52
|
}
|
|
@@ -53,6 +60,7 @@ export const DEFAULTS: Config = {
|
|
|
53
60
|
observationsPoolMaxTokens: 20_000,
|
|
54
61
|
observationsPoolTargetTokens: 10_000,
|
|
55
62
|
agentMaxTurns: 16,
|
|
63
|
+
showWorkerNotifications: true,
|
|
56
64
|
passive: false,
|
|
57
65
|
debugLog: false,
|
|
58
66
|
};
|
|
@@ -76,7 +84,49 @@ export function resolveCompactAfterTokens(config: Config, contextWindow: number
|
|
|
76
84
|
return config.compactAfterTokens;
|
|
77
85
|
}
|
|
78
86
|
|
|
79
|
-
export const THINKING_LEVEL_VALUES: readonly ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
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
|
+
}
|
|
80
130
|
|
|
81
131
|
const SETTINGS_KEY = "observational-memory";
|
|
82
132
|
const PASSIVE_ENV = "PI_OBSERVATIONAL_MEMORY_PASSIVE";
|
|
@@ -134,6 +184,7 @@ function normalizeSettingsConfig(value: Record<string, unknown>): Partial<Config
|
|
|
134
184
|
const numberKeys = [
|
|
135
185
|
"observeAfterTokens",
|
|
136
186
|
"reflectAfterTokens",
|
|
187
|
+
"observerChunkMaxTokens",
|
|
137
188
|
"compactAfterTokens",
|
|
138
189
|
"observationsPoolMaxTokens",
|
|
139
190
|
"observationsPoolTargetTokens",
|
|
@@ -148,6 +199,7 @@ function normalizeSettingsConfig(value: Record<string, unknown>): Partial<Config
|
|
|
148
199
|
}
|
|
149
200
|
const ratio = validRatioOrUndefined(value.compactAfterTokensRatio);
|
|
150
201
|
if (ratio !== undefined) normalized.compactAfterTokensRatio = ratio;
|
|
202
|
+
if (typeof value.showWorkerNotifications === "boolean") normalized.showWorkerNotifications = value.showWorkerNotifications;
|
|
151
203
|
if (typeof value.passive === "boolean") normalized.passive = value.passive;
|
|
152
204
|
if (typeof value.debugLog === "boolean") normalized.debugLog = value.debugLog;
|
|
153
205
|
const model = normalizeModel(value.model);
|
|
@@ -32,14 +32,12 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
32
32
|
return;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
const entries = ctx.sessionManager
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// window when ratio mode is configured. ctx.model is the current session model
|
|
39
|
-
// (Model<any> | undefined per ExtensionContext).
|
|
35
|
+
const entries = ctx.sessionManager?.getBranch?.() as Entry[] | undefined;
|
|
36
|
+
if (!entries) return;
|
|
37
|
+
const progress = rawTokensSinceLastCompaction(entries);
|
|
40
38
|
const contextWindow = typeof ctx.model?.contextWindow === "number" ? ctx.model.contextWindow : undefined;
|
|
41
39
|
const threshold = resolveCompactAfterTokens(runtime.config, contextWindow);
|
|
42
|
-
if (
|
|
40
|
+
if (progress < threshold) return;
|
|
43
41
|
|
|
44
42
|
// Capture ctx properties synchronously — the setTimeout + async work below
|
|
45
43
|
// may outlive the extension ctx (stale after session replacement/reload).
|
|
@@ -47,7 +45,7 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
47
45
|
const ui = ctx.ui;
|
|
48
46
|
|
|
49
47
|
if (hasUI) ui?.notify(
|
|
50
|
-
`Observational memory: compaction threshold reached (~${
|
|
48
|
+
`Observational memory: compaction threshold reached (~${progress.toLocaleString()} estimated source tokens); triggering compaction`,
|
|
51
49
|
"info",
|
|
52
50
|
);
|
|
53
51
|
|
|
@@ -62,9 +60,13 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
62
60
|
);
|
|
63
61
|
return;
|
|
64
62
|
}
|
|
65
|
-
const currentEntries = ctx.sessionManager
|
|
66
|
-
|
|
67
|
-
|
|
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) {
|
|
68
70
|
runtime.compactInFlight = false;
|
|
69
71
|
if (hasUI) ui?.notify(
|
|
70
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 {
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
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
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
|
204
|
-
`Observational memory: observer running on ~${
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
+
estimatedTokens += blockTokens;
|
|
178
232
|
}
|
|
179
|
-
|
|
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]);
|