@matthewfl/pi-contemplator 0.0.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/package.json +60 -0
  4. package/src/agents/contemplator/agent.ts +718 -0
  5. package/src/agents/contemplator/prompts.ts +212 -0
  6. package/src/agents/dropper/agent.ts +291 -0
  7. package/src/agents/dropper/coverage.ts +128 -0
  8. package/src/agents/dropper/pool.ts +67 -0
  9. package/src/agents/dropper/prompts.ts +48 -0
  10. package/src/agents/observer/agent.ts +207 -0
  11. package/src/agents/observer/prompts.ts +119 -0
  12. package/src/agents/reflector/agent.ts +213 -0
  13. package/src/agents/reflector/prompts.ts +81 -0
  14. package/src/agents/reviewer/agent.ts +187 -0
  15. package/src/agents/reviewer/history-tools.ts +337 -0
  16. package/src/agents/reviewer/prompts.ts +135 -0
  17. package/src/agents/reviewer/tools.ts +84 -0
  18. package/src/agents/stream-errors.ts +22 -0
  19. package/src/clipboard.ts +63 -0
  20. package/src/commands/contemplator-view.ts +128 -0
  21. package/src/commands/reviewer-view.ts +89 -0
  22. package/src/commands/settings.ts +257 -0
  23. package/src/commands/status.ts +176 -0
  24. package/src/commands/view.ts +171 -0
  25. package/src/config.ts +284 -0
  26. package/src/debug-log.ts +72 -0
  27. package/src/hooks/compaction-hook.ts +99 -0
  28. package/src/hooks/compaction-resume.ts +124 -0
  29. package/src/hooks/compaction-trigger.ts +122 -0
  30. package/src/hooks/consolidation-trigger.ts +488 -0
  31. package/src/ids.ts +5 -0
  32. package/src/index.ts +32 -0
  33. package/src/model-budget.ts +16 -0
  34. package/src/runtime.ts +316 -0
  35. package/src/serialize.ts +274 -0
  36. package/src/session-ledger/fold.ts +115 -0
  37. package/src/session-ledger/index.ts +7 -0
  38. package/src/session-ledger/progress.ts +156 -0
  39. package/src/session-ledger/projection.ts +243 -0
  40. package/src/session-ledger/recall.ts +258 -0
  41. package/src/session-ledger/render-summary.ts +31 -0
  42. package/src/session-ledger/search.ts +184 -0
  43. package/src/session-ledger/types.ts +329 -0
  44. package/src/tokens.ts +27 -0
  45. package/src/tools/compact-context.ts +54 -0
  46. package/src/tools/recall-observation.ts +532 -0
  47. package/src/tools/search-memories.ts +131 -0
package/src/runtime.ts ADDED
@@ -0,0 +1,316 @@
1
+ import { type Config, type ConfiguredModel, DEFAULTS, loadConfig } from "./config.js";
2
+
3
+ export type ResolveResult =
4
+ | { ok: true; model: unknown; apiKey: string; headers?: Record<string, string> }
5
+ | { ok: false; reason: string };
6
+
7
+ type NotifyLevel = "warning" | "info" | "error";
8
+ type Notify = (message: string, type?: NotifyLevel) => void;
9
+ export type ConsolidationPhase = "observer" | "reflector" | "dropper";
10
+
11
+ export const OM_SETTINGS = "om.settings";
12
+
13
+ function isConfiguredModel(value: unknown): value is ConfiguredModel {
14
+ if (!value || typeof value !== "object") return false;
15
+ const model = value as { provider?: unknown; id?: unknown; thinking?: unknown };
16
+ return typeof model.provider === "string" && model.provider.length > 0 && typeof model.id === "string" && model.id.length > 0;
17
+ }
18
+
19
+ function normalizeSessionSettings(settings: SessionSettings, baseConfig: Config): SessionSettings {
20
+ const normalized = { ...settings };
21
+ if (normalized.observationsPoolMaxTokens !== undefined && normalized.observationsPoolMaxTokens < 2) {
22
+ delete normalized.observationsPoolMaxTokens;
23
+ }
24
+ const maxTokens = normalized.observationsPoolMaxTokens ?? baseConfig.observationsPoolMaxTokens;
25
+ const targetTokens = normalized.observationsPoolTargetTokens ?? baseConfig.observationsPoolTargetTokens;
26
+ if (normalized.observationsPoolMaxTokens !== undefined && targetTokens >= maxTokens) {
27
+ normalized.observationsPoolTargetTokens = Math.floor(maxTokens / 2);
28
+ } else if (normalized.observationsPoolTargetTokens !== undefined && normalized.observationsPoolTargetTokens >= maxTokens) {
29
+ delete normalized.observationsPoolTargetTokens;
30
+ }
31
+ return normalized;
32
+ }
33
+
34
+ export type SessionSettings = Partial<Pick<Config,
35
+ | "observeAfterTokens" | "reflectAfterTokens" | "observerChunkMaxTokens" | "compactAfterTokens"
36
+ | "compactAfterTokensMode" | "compactAfterTokensRatio"
37
+ | "observationsPoolMaxTokens" | "observationsPoolTargetTokens" | "agentMaxTurns"
38
+ | "showWorkerNotifications" | "passive" | "compactionObserverEnabled" | "contemplatorEnabled" | "reviewerEnabled"
39
+ | "contemplatorMinNewObservations" | "contemplatorMinNewReflections" | "contemplatorMinTurns" | "debugLog"
40
+ >> & {
41
+ /** null explicitly means use the configured/session model. */
42
+ model?: ConfiguredModel | null;
43
+ contemplatorModel?: ConfiguredModel | null;
44
+ reviewerModel?: ConfiguredModel | null;
45
+ };
46
+
47
+ export interface ResolveCtx {
48
+ model: unknown;
49
+ modelRegistry: any;
50
+ hasUI: boolean;
51
+ ui?: { notify: Notify };
52
+ }
53
+
54
+ export interface LaunchCtx {
55
+ hasUI: boolean;
56
+ ui?: { notify: Notify };
57
+ }
58
+
59
+ export interface MemoryUpdateCtx extends LaunchCtx {
60
+ cwd: string;
61
+ model: unknown;
62
+ modelRegistry: ResolveCtx["modelRegistry"];
63
+ sessionManager: { getBranch(): readonly unknown[] };
64
+ }
65
+
66
+ export interface LlmUsageTotals {
67
+ input: number;
68
+ output: number;
69
+ cacheRead: number;
70
+ cacheWrite: number;
71
+ cost: number;
72
+ /** Number of LLM calls contributing to these totals. */
73
+ runs: number;
74
+ }
75
+
76
+ export interface LlmUsageInput {
77
+ input?: number;
78
+ output?: number;
79
+ cacheRead?: number;
80
+ cacheWrite?: number;
81
+ cost?: { total?: number };
82
+ }
83
+
84
+ /**
85
+ * Merge session-scoped settings from branch entries into a plain settings object.
86
+ * Compaction details.sessionSettings snapshots are point-in-time backups of the
87
+ * in-memory overlay, which can lag out-of-band om.settings appends, so live
88
+ * om.settings entries always win: snapshots are applied first, then live entries
89
+ * last, regardless of branch position. Per-key application means a source only
90
+ * overwrites keys it actually carries, so snapshot-only keys (whose original
91
+ * om.settings entries were folded away pre-boundary) are still preserved. Used
92
+ * both by restoreSessionSettings and by the compaction hook when baking the
93
+ * snapshot for a new compaction entry, so the two always agree.
94
+ */
95
+ export function computeSessionSettings(entries: readonly unknown[]): SessionSettings {
96
+ const restored: SessionSettings = {};
97
+ const snapshotSources: unknown[] = [];
98
+ const liveSources: unknown[] = [];
99
+ for (const entry of entries) {
100
+ if (!entry || typeof entry !== "object") continue;
101
+ const candidate = entry as { type?: unknown; customType?: unknown; data?: unknown; details?: unknown };
102
+ if (candidate.customType === OM_SETTINGS) liveSources.push(candidate.data);
103
+ if (candidate.type === "compaction" && candidate.details && typeof candidate.details === "object") {
104
+ snapshotSources.push((candidate.details as { sessionSettings?: unknown }).sessionSettings);
105
+ }
106
+ }
107
+ const applySource = (source: unknown): void => {
108
+ if (!source || typeof source !== "object") return;
109
+ const data = source as Record<string, unknown>;
110
+ const booleanKeys = [
111
+ "showWorkerNotifications", "passive", "compactionObserverEnabled", "contemplatorEnabled", "reviewerEnabled", "debugLog",
112
+ ] as const;
113
+ const numberKeys = [
114
+ "observeAfterTokens", "reflectAfterTokens", "observerChunkMaxTokens", "compactAfterTokens",
115
+ "observationsPoolMaxTokens", "observationsPoolTargetTokens", "agentMaxTurns",
116
+ "contemplatorMinNewObservations", "contemplatorMinNewReflections", "contemplatorMinTurns",
117
+ ] as const;
118
+ for (const key of booleanKeys) if (typeof data[key] === "boolean") restored[key] = data[key];
119
+ for (const key of numberKeys) if (typeof data[key] === "number" && Number.isInteger(data[key]) && data[key] > 0) restored[key] = data[key];
120
+ if (data.compactAfterTokensMode === "calibrated" || data.compactAfterTokensMode === "ratio") restored.compactAfterTokensMode = data.compactAfterTokensMode;
121
+ if (typeof data.compactAfterTokensRatio === "number" && data.compactAfterTokensRatio > 0 && data.compactAfterTokensRatio < 1) restored.compactAfterTokensRatio = data.compactAfterTokensRatio;
122
+ if (data.model === null) restored.model = null;
123
+ else if (isConfiguredModel(data.model)) restored.model = data.model;
124
+ if (data.contemplatorModel === null) restored.contemplatorModel = null;
125
+ else if (isConfiguredModel(data.contemplatorModel)) restored.contemplatorModel = data.contemplatorModel;
126
+ if (data.reviewerModel === null) restored.reviewerModel = null;
127
+ else if (isConfiguredModel(data.reviewerModel)) restored.reviewerModel = data.reviewerModel;
128
+ };
129
+ for (const source of snapshotSources) applySource(source);
130
+ for (const source of liveSources) applySource(source);
131
+ return restored;
132
+ }
133
+
134
+ export class Runtime {
135
+ config: Config = { ...DEFAULTS };
136
+ private baseConfig: Config = { ...DEFAULTS };
137
+ private sessionSettings: SessionSettings = {};
138
+ configLoaded = false;
139
+ consolidationInFlight = false;
140
+ consolidationPromise: Promise<void> | null = null;
141
+ reviewInFlight = false;
142
+ reviewPromise: Promise<void> | null = null;
143
+ private memoryUpdateListener: ((ctx: MemoryUpdateCtx) => void) | undefined;
144
+ private contextGeneration = 0;
145
+ consolidationPhase: ConsolidationPhase | undefined;
146
+ compactInFlight = false;
147
+ compactRequested = false;
148
+ compactOrigin: "proactive" | "agent-requested" | undefined;
149
+ compactHookInFlight = false;
150
+ compactionResumePending = false;
151
+ compactionResumeGeneration = 0;
152
+ compactionResumeTimer: ReturnType<typeof setTimeout> | undefined;
153
+ resolveFailureNotified = false;
154
+ lastObserverError: string | undefined;
155
+ lastReflectorError: string | undefined;
156
+ lastDropperError: string | undefined;
157
+ agentUsage: LlmUsageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, runs: 0 };
158
+
159
+ /** Accumulate usage from one background LLM call (contemplator flush/summary or observer/reflector/dropper run). */
160
+ recordAgentUsage(usage: LlmUsageInput): void {
161
+ const totals = this.agentUsage;
162
+ totals.input += usage.input ?? 0;
163
+ totals.output += usage.output ?? 0;
164
+ totals.cacheRead += usage.cacheRead ?? 0;
165
+ totals.cacheWrite += usage.cacheWrite ?? 0;
166
+ totals.cost += usage.cost?.total ?? 0;
167
+ totals.runs += 1;
168
+ }
169
+
170
+ ensureConfig(cwd: string): void {
171
+ if (this.configLoaded) return;
172
+ this.baseConfig = loadConfig(cwd);
173
+ this.config = { ...this.baseConfig };
174
+ this.configLoaded = true;
175
+ }
176
+
177
+ restoreSessionSettings(entries: readonly unknown[]): void {
178
+ this.sessionSettings = normalizeSessionSettings(computeSessionSettings(entries), this.baseConfig);
179
+ this.applySessionSettings();
180
+ }
181
+
182
+ private applySessionSettings(): void {
183
+ const { model, contemplatorModel, reviewerModel, ...scalarSettings } = this.sessionSettings;
184
+ this.config = {
185
+ ...this.baseConfig,
186
+ ...scalarSettings,
187
+ ...(model === undefined ? {} : { model: model ?? undefined }),
188
+ ...(contemplatorModel === undefined ? {} : { contemplatorModel: contemplatorModel ?? undefined }),
189
+ ...(reviewerModel === undefined ? {} : { reviewerModel: reviewerModel ?? undefined }),
190
+ };
191
+ }
192
+
193
+ setSessionSettings(settings: SessionSettings): void {
194
+ this.sessionSettings = normalizeSessionSettings({ ...this.sessionSettings, ...settings }, this.baseConfig);
195
+ this.applySessionSettings();
196
+ }
197
+
198
+ getSessionSettings(): SessionSettings {
199
+ return { ...this.sessionSettings };
200
+ }
201
+
202
+ getDefaultConfig(): Config {
203
+ return { ...this.baseConfig };
204
+ }
205
+
206
+ advanceContextGeneration(): void {
207
+ this.contextGeneration++;
208
+ // A session switch (or reload/shutdown) invalidates any in-flight or pending
209
+ // compaction state: compactRequested/compactInFlight/compactOrigin were set
210
+ // against a different branch and would otherwise leak across sessions
211
+ // (e.g. a request made in session A compacting session B's branch, or a
212
+ // never-cleared compactInFlight bricking all future compactions).
213
+ this.compactInFlight = false;
214
+ this.compactRequested = false;
215
+ this.compactOrigin = undefined;
216
+ this.compactionResumePending = false;
217
+ this.compactionResumeGeneration += 1;
218
+ if (this.compactionResumeTimer !== undefined) clearTimeout(this.compactionResumeTimer);
219
+ this.compactionResumeTimer = undefined;
220
+ }
221
+
222
+ getContextGeneration(): number {
223
+ return this.contextGeneration;
224
+ }
225
+
226
+ async resolveModel(ctx: ResolveCtx & { configuredModel?: ConfiguredModel | null }): Promise<ResolveResult> {
227
+ let model = ctx.model;
228
+ const configuredModel = ctx.configuredModel === null
229
+ ? undefined
230
+ : ctx.configuredModel ?? this.config.model;
231
+ if (configuredModel) {
232
+ const configured = ctx.modelRegistry.find(configuredModel.provider, configuredModel.id);
233
+ if (configured) {
234
+ model = configured;
235
+ } else if (ctx.hasUI && ctx.ui) {
236
+ ctx.ui.notify(
237
+ `Observational memory: configured model ${configuredModel.provider}/${configuredModel.id} not found, using session model`,
238
+ "warning",
239
+ );
240
+ }
241
+ }
242
+ if (!model) return { ok: false, reason: "no model available (session has no model and no observational-memory model configured)" };
243
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
244
+ if (!auth.ok || !auth.apiKey) {
245
+ const provider = (model as { provider?: string }).provider ?? "unknown";
246
+ return { ok: false, reason: `no API key for provider "${provider}"` };
247
+ }
248
+ return { ok: true, model, apiKey: auth.apiKey as string, headers: auth.headers as Record<string, string> | undefined };
249
+ }
250
+
251
+ setMemoryUpdateListener(listener: (ctx: MemoryUpdateCtx) => void): void {
252
+ this.memoryUpdateListener = listener;
253
+ }
254
+
255
+ notifyMemoryUpdate(ctx: MemoryUpdateCtx): void {
256
+ this.memoryUpdateListener?.(ctx);
257
+ }
258
+
259
+ launchConsolidationTask(ctx: LaunchCtx, work: () => Promise<void>): Promise<void> {
260
+ this.consolidationInFlight = true;
261
+ this.consolidationPhase = undefined;
262
+ this.lastObserverError = undefined;
263
+ this.lastReflectorError = undefined;
264
+ this.lastDropperError = undefined;
265
+ const promise = this.launchTrackedTask(ctx, "consolidation", work, () => {
266
+ this.consolidationInFlight = false;
267
+ this.consolidationPhase = undefined;
268
+ if (this.consolidationPromise === promise) this.consolidationPromise = null;
269
+ });
270
+ this.consolidationPromise = promise;
271
+ return promise;
272
+ }
273
+
274
+ launchReviewTask(ctx: LaunchCtx, work: () => Promise<void>): Promise<void> | undefined {
275
+ // Structural reviews are intentionally serialized. Pending requests are
276
+ // persisted in the session ledger and resumed after the active task exits.
277
+ if (this.reviewInFlight) return undefined;
278
+ this.reviewInFlight = true;
279
+ const promise = this.launchTrackedTask(ctx, "structural review", work, () => {
280
+ this.reviewInFlight = false;
281
+ if (this.reviewPromise === promise) this.reviewPromise = null;
282
+ });
283
+ this.reviewPromise = promise;
284
+ return promise;
285
+ }
286
+
287
+ recordConsolidationStageError(ctx: LaunchCtx, phase: ConsolidationPhase, error: unknown): string {
288
+ const message = error instanceof Error ? error.message : String(error);
289
+ if (phase === "observer") this.lastObserverError = message;
290
+ if (phase === "reflector") this.lastReflectorError = message;
291
+ if (phase === "dropper") this.lastDropperError = message;
292
+ if (ctx.hasUI && ctx.ui) ctx.ui.notify(`Observational memory: ${phase} failed: ${message}`, "warning");
293
+ return message;
294
+ }
295
+
296
+ private launchTrackedTask(
297
+ ctx: LaunchCtx,
298
+ label: string,
299
+ work: () => Promise<void>,
300
+ onFinally: (error: string | undefined) => void,
301
+ ): Promise<void> {
302
+ const hasUI = ctx.hasUI;
303
+ const ui = ctx.ui;
304
+ return (async () => {
305
+ let errorMessage: string | undefined;
306
+ try {
307
+ await work();
308
+ } catch (error) {
309
+ errorMessage = error instanceof Error ? error.message : String(error);
310
+ if (hasUI && ui) ui.notify(`Observational memory: ${label} failed: ${errorMessage}`, "warning");
311
+ } finally {
312
+ onFinally(errorMessage);
313
+ }
314
+ })();
315
+ }
316
+ }
@@ -0,0 +1,274 @@
1
+ import type { Message, TextContent, ToolResultMessage } from "@earendil-works/pi-ai";
2
+ import { estimateStringTokens } from "./tokens.js";
3
+
4
+ function pad(n: number): string {
5
+ return n.toString().padStart(2, "0");
6
+ }
7
+
8
+ function fmtLocal(d: Date): string {
9
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
10
+ }
11
+
12
+ function formatTimestamp(v: number | string | undefined): string {
13
+ if (v === undefined) return "????-??-?? ??:??";
14
+ const d = new Date(v);
15
+ return Number.isNaN(d.getTime()) ? "????-??-?? ??:??" : fmtLocal(d);
16
+ }
17
+
18
+ function formatRecallTimestamp(...values: Array<number | string | undefined>): string {
19
+ for (const v of values) {
20
+ if (v === undefined) continue;
21
+ const d = new Date(v);
22
+ if (!Number.isNaN(d.getTime())) return fmtLocal(d);
23
+ }
24
+ return "Unknown time";
25
+ }
26
+
27
+ function textAndPlaceholders(
28
+ content: unknown,
29
+ options: { omitRedactedThinking?: boolean; includeThinking?: boolean } = {},
30
+ ): string {
31
+ if (typeof content === "string") return content;
32
+ if (!Array.isArray(content)) return "[non-text content omitted]";
33
+
34
+ const parts: string[] = [];
35
+ for (const block of content as Array<Record<string, unknown>>) {
36
+ if (!block || typeof block !== "object") {
37
+ parts.push("[non-text content omitted]");
38
+ continue;
39
+ }
40
+ if (block.type === "text" && typeof block.text === "string") {
41
+ parts.push(block.text);
42
+ continue;
43
+ }
44
+ if (block.type === "thinking") {
45
+ if (options.omitRedactedThinking && block.redacted === true) continue;
46
+ if (options.includeThinking && typeof block.thinking === "string") {
47
+ parts.push(`[thinking: ${block.thinking}]`);
48
+ continue;
49
+ }
50
+ parts.push("[non-text content omitted]");
51
+ continue;
52
+ }
53
+ if (block.type === "toolCall" && typeof block.name === "string") {
54
+ parts.push(`[${block.name}(${JSON.stringify(block.arguments ?? {})})]`);
55
+ continue;
56
+ }
57
+ parts.push("[non-text content omitted]");
58
+ }
59
+ return parts.join("\n");
60
+ }
61
+
62
+ function textOnly(content: unknown): string {
63
+ if (content == null) return "";
64
+ if (typeof content === "string") return content;
65
+ if (!Array.isArray(content)) return "";
66
+ return content
67
+ .filter((b): b is TextContent => b?.type === "text" && typeof b.text === "string")
68
+ .map((b) => b.text)
69
+ .join("\n");
70
+ }
71
+
72
+ export function serializeConversation(messages: Message[]): string {
73
+ return messages
74
+ .map((msg): string | null => {
75
+ const time = formatTimestamp(msg.timestamp);
76
+ if (msg.role === "user") {
77
+ const text = textOnly(msg.content);
78
+ return `[User @ ${time}]: ${text}`;
79
+ }
80
+ if (msg.role === "assistant") {
81
+ const body = textAndPlaceholders(msg.content, {
82
+ includeThinking: true,
83
+ omitRedactedThinking: true,
84
+ })
85
+ .split("\n")
86
+ .filter(Boolean)
87
+ .join("\n");
88
+ if (!body) return null;
89
+ return `[Assistant @ ${time}]: ${body}`;
90
+ }
91
+ const text = textOnly(msg.content);
92
+ return `[Tool result for ${(msg as ToolResultMessage).toolName} @ ${time}]: ${text}`;
93
+ })
94
+ .filter((line): line is string => line !== null)
95
+ .join("\n\n");
96
+ }
97
+
98
+ export function nowTimestamp(): string {
99
+ return fmtLocal(new Date());
100
+ }
101
+
102
+ export const MAX_RECORD_CONTENT_CHARS = 10_000;
103
+
104
+ export function truncateRecordContent(content: string): string {
105
+ if (content.length <= MAX_RECORD_CONTENT_CHARS) return content;
106
+ const head = content.slice(0, MAX_RECORD_CONTENT_CHARS);
107
+ const dropped = content.length - MAX_RECORD_CONTENT_CHARS;
108
+ return `${head} … [truncated ${dropped} chars]`;
109
+ }
110
+
111
+ export type RenderableEntry = {
112
+ type: string;
113
+ id?: string;
114
+ timestamp?: string;
115
+ message?: unknown;
116
+ customType?: string;
117
+ content?: unknown;
118
+ summary?: unknown;
119
+ };
120
+
121
+ function renderCustomMessage(entry: RenderableEntry, options: { recallFormat: boolean }): string {
122
+ const time = options.recallFormat ? formatRecallTimestamp(entry.timestamp) : formatTimestamp(entry.timestamp);
123
+ const text = options.recallFormat
124
+ ? textAndPlaceholders(entry.content)
125
+ : typeof entry.content === "string"
126
+ ? entry.content
127
+ : Array.isArray(entry.content)
128
+ ? (entry.content as Array<{ type?: string; text?: string }>)
129
+ .filter((b) => b?.type === "text" && typeof b.text === "string")
130
+ .map((b) => b.text as string)
131
+ .join("\n")
132
+ : "";
133
+ if (options.recallFormat) {
134
+ const origin = entry.customType ? `Custom message (${entry.customType})` : "Custom message";
135
+ return `[${origin} @ ${time}]: ${text}`;
136
+ }
137
+ const tag = entry.customType ? `Custom (${entry.customType})` : "Custom";
138
+ return `[${tag} @ ${time}]: ${text}`;
139
+ }
140
+
141
+ export function serializeBranchEntries(entries: RenderableEntry[]): string {
142
+ const blocks: string[] = [];
143
+ for (const entry of entries) {
144
+ if (entry.type === "message" && entry.message) {
145
+ const part = serializeConversation([entry.message as Message]);
146
+ if (part) blocks.push(part);
147
+ continue;
148
+ }
149
+ if (entry.type === "custom_message") {
150
+ blocks.push(renderCustomMessage(entry, { recallFormat: false }));
151
+ continue;
152
+ }
153
+ if (entry.type === "branch_summary" && typeof entry.summary === "string") {
154
+ const time = formatTimestamp(entry.timestamp);
155
+ blocks.push(`[Branch summary @ ${time}]: ${entry.summary}`);
156
+ }
157
+ }
158
+ return blocks.join("\n\n");
159
+ }
160
+
161
+ export type SourceAddressedSerialization = {
162
+ text: string;
163
+ sourceEntryIds: string[];
164
+ estimatedTokens: number;
165
+ truncatedSourceEntryIds: string[];
166
+ };
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
+
189
+ function isSourceRenderableEntry(entry: RenderableEntry): boolean {
190
+ return entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary";
191
+ }
192
+
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 {
203
+ const blocks: string[] = [];
204
+ const sourceEntryIds: string[] = [];
205
+ const truncatedSourceEntryIds: string[] = [];
206
+ let estimatedTokens = 0;
207
+
208
+ for (const entry of entries) {
209
+ if (!entry.id || !isSourceRenderableEntry(entry)) continue;
210
+ const rendered = serializeBranchEntries([entry]);
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);
230
+ sourceEntryIds.push(entry.id);
231
+ estimatedTokens += blockTokens;
232
+ }
233
+
234
+ const text = blocks.join("\n\n");
235
+ return { text, sourceEntryIds, estimatedTokens: estimateStringTokens(text), truncatedSourceEntryIds };
236
+ }
237
+
238
+ function renderRecallMessage(entry: RenderableEntry): string | null {
239
+ if (!entry.message || typeof entry.message !== "object") return null;
240
+ const msg = entry.message as Message;
241
+ const time = formatRecallTimestamp(msg.timestamp, entry.timestamp);
242
+ if (msg.role === "user") {
243
+ return `[User @ ${time}]: ${textAndPlaceholders(msg.content)}`;
244
+ }
245
+ if (msg.role === "assistant") {
246
+ const body = textAndPlaceholders(msg.content, {
247
+ includeThinking: true,
248
+ omitRedactedThinking: true,
249
+ })
250
+ .split("\n")
251
+ .filter(Boolean)
252
+ .join("\n");
253
+ if (!body) return null;
254
+ return `[Assistant @ ${time}]: ${body}`;
255
+ }
256
+ return `[Tool result: ${(msg as ToolResultMessage).toolName} @ ${time}]: ${textAndPlaceholders(msg.content)}`;
257
+ }
258
+
259
+ export function renderRecallSourceEntry(entry: RenderableEntry): string | null {
260
+ if (entry.type === "message") return renderRecallMessage(entry);
261
+ if (entry.type === "custom_message") return renderCustomMessage(entry, { recallFormat: true });
262
+ if (entry.type === "branch_summary" && typeof entry.summary === "string") {
263
+ const time = formatRecallTimestamp(entry.timestamp);
264
+ return `[Branch summary @ ${time}]: ${entry.summary}`;
265
+ }
266
+ return null;
267
+ }
268
+
269
+ export function renderRecallSourceEntries(entries: RenderableEntry[]): string {
270
+ return entries
271
+ .map(renderRecallSourceEntry)
272
+ .filter((block): block is string => block !== null && block.trim().length > 0)
273
+ .join("\n\n");
274
+ }