pi-observational-memory 3.0.3 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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]);