pi-observational-memory 3.0.3 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -11
- package/package.json +5 -5
- package/src/agents/dropper/agent.ts +20 -5
- package/src/agents/dropper/pool.ts +6 -2
- package/src/agents/observer/agent.ts +53 -8
- package/src/agents/reflector/agent.ts +20 -5
- package/src/agents/stream-errors.ts +22 -0
- package/src/agents/worker-stream.ts +65 -0
- package/src/commands/status.ts +1 -1
- package/src/config.ts +63 -1
- package/src/hooks/compaction-hook.ts +10 -2
- package/src/hooks/compaction-trigger.ts +15 -34
- package/src/hooks/consolidation-trigger.ts +165 -34
- package/src/runtime.ts +227 -5
- package/src/serialize.ts +59 -3
- package/src/session-ledger/progress.ts +100 -0
- package/src/tokens.ts +18 -0
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]);
|