pi-observational-memory-extension 0.1.0

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.
@@ -0,0 +1,1400 @@
1
+ import { complete } from "@earendil-works/pi-ai";
2
+ import {
3
+ CONFIG_DIR_NAME,
4
+ convertToLlm,
5
+ serializeConversation,
6
+ type ExtensionAPI,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
9
+ import { Type } from "typebox";
10
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
11
+ import { existsSync } from "node:fs";
12
+ import { basename, dirname, join } from "node:path";
13
+
14
+ const EXTENSION_ID = "pi-observational-memory";
15
+ const STATE_VERSION = 1;
16
+ const DEFAULT_OBSERVATION_MODEL = "google/gemini-2.5-flash";
17
+ const DEFAULT_REFLECTION_MODEL = "google/gemini-2.5-flash";
18
+ const OBSERVATION_THRESHOLD = 30_000;
19
+ const REFLECTION_THRESHOLD = 40_000;
20
+ const OBSERVER_MAX_BATCH_TOKENS = 10_000;
21
+ const BUFFER_TOKENS_RATIO = 0.2;
22
+ const BUFFER_ACTIVATION_RATIO = 0.8;
23
+ const DEFAULT_BLOCK_AFTER_MULTIPLIER = 1.2;
24
+ const TOOL_RESULT_MAX_CHARS = 8_000;
25
+ const MESSAGE_PART_MAX_CHARS = 20_000;
26
+ const MAX_OBSERVATION_LINE_CHARS = 10_000;
27
+ const MAX_RESTART_ERRORS = 3;
28
+
29
+ const OBSERVATION_CONTEXT_PROMPT = `The following observations block contains your memory of past conversations with this user.`;
30
+ const OBSERVATION_CONTEXT_INSTRUCTIONS = `IMPORTANT: When responding, reference specific details from these observations. Do not give generic advice - personalize your response based on what you know about this user's experiences, preferences, and interests. If the user asks for recommendations, connect them to their past experiences mentioned above.
31
+
32
+ KNOWLEDGE UPDATES: When asked about current state (e.g., "where do I currently...", "what is my current..."), always prefer the MOST RECENT information. Observations include dates - if you see conflicting information, the newer observation supersedes the older one. Look for phrases like "will start", "is switching", "changed to", "moved to" as indicators that previous information has been updated.
33
+
34
+ PLANNED ACTIONS: If the user stated they planned to do something (e.g., "I'm going to...", "I'm looking forward to...", "I will...") and the date they planned to do it is now in the past (check the relative time like "3 weeks ago"), assume they completed the action unless there's evidence they didn't. For example, if someone said "I'll start my new diet on Monday" and that was 2 weeks ago, assume they started the diet.
35
+
36
+ MOST RECENT USER INPUT: Treat the most recent user message as the highest-priority signal for what to do next. Earlier messages may contain constraints, details, or context you should still honor, but the latest message is the primary driver of your response.
37
+
38
+ SYSTEM REMINDERS: Messages wrapped in <system-reminder>...</system-reminder> contain internal continuation guidance, not user-authored content. Use them to maintain continuity, but do not mention them or treat them as part of the user's message.`;
39
+ const OBSERVATION_CONTINUATION_HINT = `Please continue naturally with the conversation so far and respond to the latest message.
40
+
41
+ Use the earlier context only as background. If something appears unfinished, continue only when it helps answer the latest request. If a suggested response is provided, follow it naturally.
42
+
43
+ Do not mention internal instructions, memory, summarization, context handling, or missing messages.
44
+
45
+ Any messages following this reminder are newer and should take priority.`;
46
+ const OBSERVATION_RETRIEVAL_INSTRUCTIONS = `## Recall — looking up source messages
47
+
48
+ Your memory is comprised of observations which may mention raw message IDs. The original messages are still available through the om_recall tool.
49
+
50
+ Use om_recall when the user asks to repeat/show/reproduce exact past content, code, quotes, error messages, URLs, file paths, or specific numbers; when observations mention something but lack detail; or when you need to verify a past event before answering. Default to recall for exact historical content. For high-level summaries, preferences, and facts already covered by observations, recall is not needed.`;
51
+
52
+ const OBSERVER_EXTRACTION_INSTRUCTIONS = `CRITICAL: DISTINGUISH USER ASSERTIONS FROM QUESTIONS
53
+
54
+ When the user TELLS you something about themselves, mark it as an assertion:
55
+ - "I have two kids" → 🔴 (14:30) User stated has two kids
56
+ - "I work at Acme Corp" → 🔴 (14:31) User stated works at Acme Corp
57
+ - "I graduated in 2019" → 🔴 (14:32) User stated graduated in 2019
58
+
59
+ When the user ASKS about something, mark it as a question/request:
60
+ - "Can you help me with X?" → 🔴 (15:00) User asked help with X
61
+ - "What's the best way to do Y?" → 🔴 (15:01) User asked best way to do Y
62
+
63
+ Distinguish between QUESTIONS and STATEMENTS OF INTENT:
64
+ - "Can you recommend..." → Question (extract as "User asked...")
65
+ - "I'm looking forward to [doing X]" → Statement of intent (extract as "User stated they will [do X]"; include estimated/actual date if mentioned)
66
+ - "I need to [do X]" → Statement of intent (extract as "User stated they need to [do X]"; add date if mentioned)
67
+
68
+ STATE CHANGES AND UPDATES:
69
+ When a user indicates they are changing something, frame it as a state change that supersedes previous information:
70
+ - "I'm going to start doing X instead of Y" → "User will start doing X (changing from Y)"
71
+ - "I'm switching from A to B" → "User is switching from A to B"
72
+ - "I moved my stuff to the new place" → "User moved their stuff to the new place (no longer at previous location)"
73
+
74
+ If the new state contradicts or updates previous information, make that explicit:
75
+ - BAD: "User plans to use the new method"
76
+ - GOOD: "User will use the new method (replacing the old approach)"
77
+
78
+ USER ASSERTIONS ARE AUTHORITATIVE. The user is the source of truth about their own life. If a user previously stated something and later asks a question about the same topic, the assertion is the answer - the question doesn't invalidate what they already told you.
79
+
80
+ TEMPORAL ANCHORING:
81
+ Each observation has TWO potential timestamps:
82
+ 1. BEGINNING: The time the statement was made (from the message timestamp) - ALWAYS include this
83
+ 2. END: The time being REFERENCED, if different from when it was said - ONLY when there's a relative time reference and you can provide an actual date.
84
+
85
+ FORMAT:
86
+ - With time reference: (TIME) [observation]. (meaning/estimated DATE)
87
+ - Without time reference: (TIME) [observation].
88
+ ALWAYS put referenced dates at the END in parentheses.
89
+ If an observation contains MULTIPLE events, split them into SEPARATE observation lines. Each split observation MUST have its own date at the end when applicable.
90
+
91
+ PRESERVE UNUSUAL PHRASING:
92
+ When the user uses unexpected or non-standard terminology, quote their exact words.
93
+
94
+ USE PRECISE ACTION VERBS:
95
+ Replace vague verbs like "getting", "got", "have" with specific action verbs that clarify the action. Prefer the assistant's precise clarification if it confirmed the user's meaning.
96
+
97
+ PRESERVING DETAILS IN ASSISTANT-GENERATED CONTENT:
98
+ Preserve distinguishing details in recommendation lists, names/handles/identifiers, creative content structure, technical/numerical results, quantities/counts, and user roles at events. Always preserve specific values, counts, identifiers, units, file paths, URLs, code snippets, and formatted text that may need to be reproduced.
99
+
100
+ CONVERSATION CONTEXT:
101
+ Capture what the user is working on, previous topics/outcomes, what the user understands or needs clarified, specific requirements/constraints, assistant explanations and detailed answers, relevant code snippets, user preferences, iteratively edited text, named entities and their identifying attributes, and any important sequences/data.
102
+
103
+ USER MESSAGE CAPTURE:
104
+ Short and medium user messages should be captured nearly verbatim in your own words. For very long user messages, summarize but quote key phrases that carry intent.
105
+
106
+ AVOIDING REPETITIVE OBSERVATIONS:
107
+ Do NOT repeat the same observation across multiple turns if there is no new information. Group repeated similar actions and tool calls under one parent observation with sub-bullets for new results. Observe what tools were called, why, and what was learned; do not list tools mechanically.
108
+
109
+ ACTIONABLE INSIGHTS:
110
+ Capture what worked, what needs follow-up, stated goals or next steps, and whether any non-requested next steps are waiting for user approval.
111
+
112
+ COMPLETION TRACKING:
113
+ Use ✅ for concrete task finished, question answered, issue resolved, goal achieved, or subtask completed. Completion observations tell the assistant what should not be repeated unless new information changes it. Prefer concrete resolved outcomes over vague progress summaries.`;
114
+
115
+ function buildObserverOutputFormat(): string {
116
+ return `Use priority levels:
117
+ - 🔴 High: explicit user facts, preferences, unresolved goals, critical context
118
+ - 🟡 Medium: project details, learned information, tool results
119
+ - 🟢 Low: minor details, uncertain observations
120
+ - ✅ Completed: concrete task finished, question answered, issue resolved, goal achieved, or subtask completed
121
+
122
+ Group related observations by indenting:
123
+ * 🔴 (14:33) Agent debugging auth issue
124
+ * -> ran git status, found 3 modified files
125
+ * -> viewed auth.ts:45-60, found missing null check
126
+ * -> applied fix, tests now pass
127
+ * ✅ Tests passing, auth issue resolved
128
+
129
+ Group observations by date, then list each with 24-hour time.
130
+
131
+ <observations>
132
+ Date: Dec 4, 2025
133
+ * 🔴 (14:30) User prefers direct answers
134
+ * 🔴 (14:31) Working on feature X
135
+ * 🟡 (14:32) User might prefer dark mode
136
+
137
+ Date: Dec 5, 2025
138
+ * 🔴 (09:15) Continued work on feature X
139
+ </observations>
140
+
141
+ <current-task>
142
+ State the current task(s) explicitly. Include primary current work and secondary pending tasks. Mark tasks as "waiting for user" where appropriate. If the agent started doing something without user approval, note that it's off-task.
143
+ </current-task>
144
+
145
+ <suggested-response>
146
+ Hint for the agent's immediate next message. Examples:
147
+ - "I've updated the navigation model. Let me walk you through the changes..."
148
+ - "The assistant should wait for the user to respond before continuing."
149
+ - Call the read tool on src/example.ts to continue debugging.
150
+ </suggested-response>`;
151
+ }
152
+
153
+ const OBSERVER_GUIDELINES = `- Be specific enough for the assistant to act on
154
+ - Good: "User prefers short, direct answers without lengthy explanations"
155
+ - Bad: "User stated a preference" (too vague)
156
+ - Add 1 to 5 observations per exchange
157
+ - Use terse, dense language to save tokens
158
+ - Do not add repetitive observations already observed
159
+ - If the agent calls tools, observe what was called, why, and what was learned
160
+ - Include file line numbers when useful
161
+ - If the agent provides a detailed response, observe enough contents that it could be repeated
162
+ - Start each observation with 🔴, 🟡, 🟢, or ✅
163
+ - Capture the user's words closely; unresolved or critical user facts remain 🔴
164
+ - Treat ✅ as a memory signal that tells the assistant what is finished
165
+ - Observe WHAT the agent did and WHAT it means
166
+ - If the user provides detailed messages or code snippets, observe all important details`;
167
+
168
+ function buildObserverSystemPrompt(): string {
169
+ return `You are the memory consciousness of an AI assistant. Your observations will be the ONLY information the assistant has about past interactions with this user.
170
+
171
+ Extract observations that will help the assistant remember:
172
+
173
+ ${OBSERVER_EXTRACTION_INSTRUCTIONS}
174
+
175
+ === OUTPUT FORMAT ===
176
+
177
+ Your output MUST use XML tags to structure the response. This allows the system to properly parse and manage memory over time.
178
+
179
+ ${buildObserverOutputFormat()}
180
+
181
+ === GUIDELINES ===
182
+
183
+ ${OBSERVER_GUIDELINES}
184
+
185
+ === IMPORTANT: THREAD ATTRIBUTION ===
186
+
187
+ Do NOT add thread identifiers, thread IDs, or <thread> tags to your observations. Thread attribution is handled externally by the system. Simply output your observations without thread-related markup.
188
+
189
+ Remember: These observations are the assistant's ONLY memory. Make them count.
190
+
191
+ User messages are extremely important. If the user asks a question or gives a new task, make it clear in <current-task> that this is the priority. If the assistant needs to respond to the user, indicate in <suggested-response> that it should pause for user reply before continuing other tasks.`;
192
+ }
193
+
194
+ function buildObserverTaskPrompt(existingObservations: string | undefined, opts: { priorCurrentTask?: string; priorSuggestedResponse?: string; wasTruncated?: boolean } = {}): string {
195
+ let prompt = "";
196
+ if (existingObservations?.trim()) {
197
+ prompt += `## Previous Observations\n\n${existingObservations}\n\n---\n\nDo not repeat these existing observations. Your new observations will be appended to the existing observations.\n\n`;
198
+ }
199
+ const metadata: string[] = [];
200
+ if (opts.priorCurrentTask) metadata.push(`- prior current-task: ${opts.priorCurrentTask}`);
201
+ if (opts.priorSuggestedResponse) metadata.push(`- prior suggested-response: ${opts.priorSuggestedResponse}`);
202
+ if (metadata.length) {
203
+ prompt += `## Prior Thread Metadata\n\n${metadata.join("\n")}\n\n`;
204
+ if (opts.wasTruncated) {
205
+ prompt += `Previous observations were truncated for context budget reasons. The main agent still has full memory context outside this observer window.\n`;
206
+ }
207
+ prompt += `Use prior current-task and suggested-response as continuity hints, then update them based on the new messages.\n\n---\n\n`;
208
+ }
209
+ prompt += `## Your Task\n\nExtract new observations from the message history above. Do not repeat observations that are already in previous observations. Add your new observations in the format specified in your instructions.`;
210
+ return prompt;
211
+ }
212
+
213
+ function buildReflectorSystemPrompt(): string {
214
+ return `You are the memory consciousness of an AI assistant. Your memory observation reflections will be the ONLY information the assistant has about past interactions with this user.
215
+
216
+ The following instructions were given to another part of your psyche (the observer) to create memories.
217
+
218
+ <observational-memory-instruction>
219
+ ${OBSERVER_EXTRACTION_INSTRUCTIONS}
220
+
221
+ === OUTPUT FORMAT ===
222
+
223
+ ${buildObserverOutputFormat()}
224
+
225
+ === GUIDELINES ===
226
+
227
+ ${OBSERVER_GUIDELINES}
228
+ </observational-memory-instruction>
229
+
230
+ You are another part of the same psyche, the observation reflector. Your reason for existing is to reflect on all the observations, re-organize and streamline them, and draw connections and conclusions between observations about what you've learned, seen, heard, and done.
231
+
232
+ You are a greater and broader aspect of the psyche. Understand that other parts of your mind may get off track in details or side quests; identify the observed goal, whether we got off track, why, and how to get back on track.
233
+
234
+ IMPORTANT: your reflections are THE ENTIRETY of the assistant's memory. Any information you do not add to your reflections will be immediately forgotten. Your reflections must assume the assistant knows nothing.
235
+
236
+ When consolidating observations:
237
+ - Preserve dates/times when present
238
+ - Retain relevant timestamps
239
+ - Combine related items where it makes sense
240
+ - Preserve ✅ completion markers and concrete resolved outcomes
241
+ - Condense older observations more aggressively, retain more detail for recent ones
242
+ - Preserve user assertions over questions and newer state over superseded older state
243
+ - Maintain thread attribution if it appears and matters; consolidate universal cross-thread facts
244
+
245
+ === OUTPUT FORMAT ===
246
+
247
+ Your output MUST use XML tags:
248
+
249
+ <observations>
250
+ Put all consolidated observations here using date-grouped format with priority emojis. Group related observations with indentation.
251
+ </observations>
252
+
253
+ <current-task>
254
+ State current task(s) explicitly: primary and secondary pending tasks. Mark waiting-for-user tasks.
255
+ </current-task>
256
+
257
+ <suggested-response>
258
+ Hint for the agent's immediate next message.
259
+ </suggested-response>
260
+
261
+ User messages are extremely important. If the user asks a question or gives a new task, make it clear in <current-task> that this is the priority. If the assistant needs to respond, indicate in <suggested-response> that it should pause for user reply before continuing other tasks.`;
262
+ }
263
+
264
+ type CompressionLevel = 0 | 1 | 2 | 3 | 4;
265
+ const COMPRESSION_GUIDANCE: Record<CompressionLevel, string> = {
266
+ 0: "",
267
+ 1: `\n## COMPRESSION REQUIRED\n\nYour previous reflection was the same size or larger than the original observations. Re-process with slightly more compression: condense older observations, retain recent detail, combine repeated tool calls into outcome summaries, preserve ✅ markers and important specifics. Aim for 8/10 detail.`,
268
+ 2: `\n## AGGRESSIVE COMPRESSION REQUIRED\n\nYour previous reflection was still too large. Heavily condense older observations, retain recent important details, merge repeated files/modules, remove redundancy, preserve names/dates/decisions/errors/preferences/✅ outcomes. Aim for 6/10 detail.`,
269
+ 3: `\n## CRITICAL COMPRESSION REQUIRED\n\nSummarize the oldest 50-70% into brief high-level paragraphs. Retain only key facts, decisions, outcomes, unresolved issues, user preferences, architectural choices, and recent details. Drop procedural tool details; keep final outcomes. Aim for 4/10 detail.`,
270
+ 4: `\n## EXTREME COMPRESSION REQUIRED\n\nTool call observations are the biggest source of bloat. Collapse ALL tool sequences into outcome-only observations. Never preserve individual tool calls. Merge same-day groups, keep older topics to 1-2 observations, preserve ✅ outcomes/user preferences/decisions/unresolved issues. Aim for 2/10 detail.`,
271
+ };
272
+
273
+ function buildReflectorPrompt(observations: string, level: CompressionLevel, manualPrompt?: string): string {
274
+ let prompt = `## OBSERVATIONS TO REFLECT ON\n\n${observations}\n\n---\n\nPlease analyze these observations and produce a refined, condensed version that will become the assistant's entire memory going forward.`;
275
+ if (manualPrompt) prompt += `\n\n## SPECIFIC GUIDANCE\n\n${manualPrompt}`;
276
+ if (COMPRESSION_GUIDANCE[level]) prompt += `\n\n${COMPRESSION_GUIDANCE[level]}`;
277
+ return prompt;
278
+ }
279
+
280
+ type Status = "idle" | "observing" | "reflecting" | "buffering" | "failed" | "disabled";
281
+ type OperationType = "observation" | "reflection" | "buffer" | "activation";
282
+
283
+ type BufferedChunk = {
284
+ id: string;
285
+ startEntryId?: string;
286
+ endEntryId: string;
287
+ messageTokens: number;
288
+ observationTokens: number;
289
+ observations: string;
290
+ currentTask?: string;
291
+ suggestedResponse?: string;
292
+ createdAt: string;
293
+ };
294
+
295
+ type PiOMRecord = {
296
+ version: number;
297
+ enabled: boolean;
298
+ sessionId: string;
299
+ sessionFile?: string;
300
+ cwd: string;
301
+ scope: "session" | "project";
302
+ status: Status;
303
+ observations: string;
304
+ currentTask?: string;
305
+ suggestedResponse?: string;
306
+ lastObservedEntryId?: string;
307
+ pendingMessageTokens: number;
308
+ observationTokens: number;
309
+ thresholds: { observation: number; reflection: number; blockAfter: number; bufferTokens: number; bufferActivation: number };
310
+ buffered: { observations: BufferedChunk[]; reflection?: BufferedChunk };
311
+ operationLock?: { type: OperationType; startedAt: string };
312
+ lastOperation?: { type: OperationType; startedAt: string; endedAt?: string; inputTokens: number; outputTokens?: number; error?: string; model?: string; compressionLevel?: number };
313
+ lastError?: string;
314
+ lastProvider?: string;
315
+ lastModel?: string;
316
+ updatedAt: string;
317
+ };
318
+
319
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
320
+ type AgentMessage = any;
321
+
322
+ type ObserverResult = { observations: string; currentTask?: string; suggestedContinuation?: string; rawOutput: string; degenerate?: boolean };
323
+ type ReflectorResult = { observations: string; currentTask?: string; suggestedContinuation?: string; rawOutput: string; degenerate?: boolean };
324
+
325
+ type Runtime = {
326
+ state?: PiOMRecord;
327
+ statePath?: string;
328
+ debugDir?: string;
329
+ overlay?: OMOverlay;
330
+ overlayHandle?: { requestRender: () => void; close: () => void };
331
+ currentOperation?: AbortController;
332
+ statusTimer?: NodeJS.Timeout;
333
+ failureCount: number;
334
+ };
335
+
336
+ const runtime: Runtime = { failureCount: 0 };
337
+
338
+ export default function (pi: ExtensionAPI) {
339
+ pi.registerTool({
340
+ name: "om_recall",
341
+ label: "OM Recall",
342
+ description: "Recall raw Pi session messages hidden by Observational Memory pruning. Use for exact past content, code, quotes, file paths, URLs, or verification.",
343
+ promptSnippet: "Recall raw messages hidden by Observational Memory when exact past content is needed.",
344
+ promptGuidelines: ["Use om_recall when observations are insufficient for exact past content, code, quotes, file paths, URLs, or verification."],
345
+ parameters: Type.Object({
346
+ cursor: Type.Optional(Type.String({ description: "Entry id to start near. Defaults to last observed entry." })),
347
+ page: Type.Optional(Type.Number({ description: "Page offset from cursor. 0/current, positive forward, negative backward." })),
348
+ radius: Type.Optional(Type.Number({ description: "Messages around target entry. Default 6, max 20." })),
349
+ detail: Type.Optional(Type.Union([Type.Literal("low"), Type.Literal("high")], { description: "low truncates payloads; high returns more detail." })),
350
+ }),
351
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
352
+ const state = await ensureState(ctx);
353
+ const branch = ctx.sessionManager.getBranch() as SessionEntry[];
354
+ const cursor = params.cursor || state.lastObservedEntryId || branch.at(-1)?.id;
355
+ const detail = params.detail === "high" ? "high" : "low";
356
+ const radius = Math.min(Math.max(Number(params.radius ?? 6), 1), 20);
357
+ const page = Number(params.page ?? 0);
358
+ const idx = Math.max(0, branch.findIndex(e => e.id === cursor));
359
+ const center = Math.max(0, Math.min(branch.length - 1, idx + page * radius));
360
+ const start = Math.max(0, center - radius);
361
+ const end = Math.min(branch.length, center + radius + 1);
362
+ const entries = branch.slice(start, end).filter(isMessageLikeEntry);
363
+ const text = entries.map(e => serializeEntryForRecall(e, detail)).join("\n\n---\n\n");
364
+ return {
365
+ content: [{ type: "text", text: text || "No recallable messages near cursor." }],
366
+ details: { cursor, page, startEntryId: entries[0]?.id, endEntryId: entries.at(-1)?.id, hasPrevPage: start > 0, hasNextPage: end < branch.length },
367
+ };
368
+ },
369
+ });
370
+
371
+ pi.on("session_start", async (_event, ctx) => {
372
+ await ensureState(ctx);
373
+ await refreshCounts(ctx);
374
+ updateStatus(ctx);
375
+ runtime.statusTimer = setInterval(() => updateStatus(ctx), 1000);
376
+ });
377
+
378
+ pi.on("session_shutdown", async (_event, ctx) => {
379
+ if (runtime.statusTimer) clearInterval(runtime.statusTimer);
380
+ runtime.currentOperation?.abort();
381
+ if (runtime.state) await saveState(runtime.state);
382
+ ctx.ui.setStatus("om", undefined);
383
+ ctx.ui.setWidget("om", undefined);
384
+ });
385
+
386
+ pi.on("model_select", async (event, ctx) => {
387
+ const state = await ensureState(ctx);
388
+ const nextProvider = event.model?.provider;
389
+ const nextModel = event.model?.id;
390
+ const changed = state.lastProvider && state.lastModel && (state.lastProvider !== nextProvider || state.lastModel !== nextModel);
391
+ state.lastProvider = nextProvider;
392
+ state.lastModel = nextModel;
393
+ if (changed && state.buffered.observations.length > 0) {
394
+ await activateBuffered(ctx, "provider/model changed");
395
+ }
396
+ await saveState(state);
397
+ updateStatus(ctx);
398
+ });
399
+
400
+ pi.on("message_end", async (_event, ctx) => {
401
+ await safeBoundary(ctx, "message_end");
402
+ });
403
+
404
+ pi.on("tool_execution_end", async (_event, ctx) => {
405
+ await safeBoundary(ctx, "tool_execution_end");
406
+ });
407
+
408
+ pi.on("turn_end", async (_event, ctx) => {
409
+ await safeBoundary(ctx, "turn_end");
410
+ });
411
+
412
+ pi.on("context", async (event, ctx) => {
413
+ const state = await ensureState(ctx);
414
+ if (!state.enabled || !state.observations.trim()) return;
415
+ const branch = ctx.sessionManager.getBranch() as SessionEntry[];
416
+ const unobserved = entriesAfter(branch, state.lastObservedEntryId)
417
+ .filter(isMessageLikeEntry)
418
+ .map(entryToAgentMessage)
419
+ .filter(Boolean) as AgentMessage[];
420
+ const omMessage = buildOMContextMessage(state);
421
+ const finalMessages = [omMessage, ...unobserved];
422
+ if (finalMessages.length <= 1) return { messages: [omMessage, ...event.messages.slice(-8)] };
423
+ return { messages: finalMessages };
424
+ });
425
+
426
+ pi.on("session_before_compact", async (event, ctx) => {
427
+ const state = await ensureState(ctx);
428
+ if (!state.enabled) return;
429
+ const startedAt = new Date().toISOString();
430
+ try {
431
+ ctx.ui.notify("OM compact: observing/reflection instead of legacy summary", "info");
432
+ await refreshCounts(ctx);
433
+ const prep = event.preparation as any;
434
+ const messages = [...(prep.messagesToSummarize ?? []), ...(prep.turnPrefixMessages ?? [])];
435
+ if (messages.length > 0) {
436
+ const text = serializeConversation(convertToLlm(messages));
437
+ const result = await runObserver(ctx, text, event.signal, { source: "compact", existingObservations: state.observations });
438
+ appendObservations(state, result, estimateTokens(text));
439
+ } else {
440
+ await observeNow(ctx, { force: true, signal: event.signal, reason: "compact" });
441
+ }
442
+ if (state.observationTokens >= state.thresholds.reflection) {
443
+ await reflectNow(ctx, { signal: event.signal, reason: "compact" });
444
+ }
445
+ const branch = ctx.sessionManager.getBranch() as SessionEntry[];
446
+ const beforeKept = entriesBefore(branch, prep.firstKeptEntryId).at(-1);
447
+ if (beforeKept?.id) state.lastObservedEntryId = beforeKept.id;
448
+ state.pendingMessageTokens = Math.max(0, state.pendingMessageTokens - estimateMessagesTokens(messages));
449
+ state.status = "idle";
450
+ state.updatedAt = new Date().toISOString();
451
+ await saveState(state);
452
+ updateStatus(ctx);
453
+ const summary = buildCompactionSummary(state);
454
+ await writeDebug(ctx, "compact", { startedAt, summary, state });
455
+ return { compaction: { summary, firstKeptEntryId: prep.firstKeptEntryId, tokensBefore: prep.tokensBefore, details: { type: EXTENSION_ID, om: snapshotDetails(state) } } };
456
+ } catch (error) {
457
+ const message = errorMessage(error);
458
+ state.status = "failed";
459
+ state.lastError = `OM compact failed: ${message}`;
460
+ await saveState(state);
461
+ updateStatus(ctx);
462
+ ctx.ui.notify(state.lastError, "error");
463
+ throw error;
464
+ }
465
+ });
466
+
467
+ pi.registerCommand("om-status", {
468
+ description: "Show Observational Memory status.",
469
+ handler: async (_args, ctx) => {
470
+ const state = await ensureState(ctx);
471
+ await refreshCounts(ctx);
472
+ ctx.ui.notify(formatDetailedStatus(state), state.status === "failed" ? "error" : "info");
473
+ updateStatus(ctx);
474
+ },
475
+ });
476
+
477
+ pi.registerCommand("om-observe", {
478
+ description: "Force Observational Memory observation of pending raw context.",
479
+ handler: async (_args, ctx) => {
480
+ const state = await ensureState(ctx);
481
+ if (!state.enabled) throw new Error("Observational Memory is disabled");
482
+ await observeNow(ctx, { force: true, reason: "manual" });
483
+ updateStatus(ctx);
484
+ ctx.ui.notify("OM observation complete", "info");
485
+ },
486
+ });
487
+
488
+ pi.registerCommand("om-reflect", {
489
+ description: "Force Observational Memory reflection/compression.",
490
+ handler: async (args, ctx) => {
491
+ const state = await ensureState(ctx);
492
+ if (!state.enabled) throw new Error("Observational Memory is disabled");
493
+ await reflectNow(ctx, { reason: "manual", manualPrompt: args?.trim() || undefined });
494
+ updateStatus(ctx);
495
+ ctx.ui.notify("OM reflection complete", "info");
496
+ },
497
+ });
498
+
499
+ pi.registerCommand("om-memory", {
500
+ description: "Open Observational Memory overlay.",
501
+ handler: async (_args, ctx) => {
502
+ const state = await ensureState(ctx);
503
+ if (ctx.mode !== "tui") {
504
+ ctx.ui.notify(formatMemoryText(state), "info");
505
+ return;
506
+ }
507
+ runtime.overlay = new OMOverlay(() => runtime.state, () => {
508
+ runtime.overlayHandle?.close();
509
+ runtime.overlayHandle = undefined;
510
+ });
511
+ await ctx.ui.custom((tui, theme, _kb, done) => {
512
+ runtime.overlay = new OMOverlay(() => runtime.state, () => done(undefined), () => tui.requestRender(), theme);
513
+ return runtime.overlay;
514
+ }, {
515
+ overlay: true,
516
+ overlayOptions: { width: "95%", maxHeight: "90%", anchor: "center", margin: 1 },
517
+ onHandle: (handle: any) => {
518
+ runtime.overlayHandle = handle;
519
+ handle.focus?.();
520
+ },
521
+ });
522
+ },
523
+ });
524
+
525
+ pi.registerCommand("om-enable", {
526
+ description: "Enable Observational Memory for this session.",
527
+ handler: async (_args, ctx) => {
528
+ const state = await ensureState(ctx);
529
+ state.enabled = true;
530
+ state.status = "idle";
531
+ await saveState(state);
532
+ updateStatus(ctx);
533
+ ctx.ui.notify("Observational Memory enabled", "info");
534
+ },
535
+ });
536
+
537
+ pi.registerCommand("om-disable", {
538
+ description: "Disable Observational Memory for this session.",
539
+ handler: async (_args, ctx) => {
540
+ const state = await ensureState(ctx);
541
+ state.enabled = false;
542
+ state.status = "disabled";
543
+ await saveState(state);
544
+ updateStatus(ctx);
545
+ ctx.ui.notify("Observational Memory disabled", "warning");
546
+ },
547
+ });
548
+
549
+ pi.registerCommand("om-compact", {
550
+ description: "Run Pi compaction; pi-observational-memory will replace the summary with OM.",
551
+ handler: async (args, ctx) => {
552
+ ctx.compact({
553
+ customInstructions: args || "Use Observational Memory compaction.",
554
+ onComplete: () => ctx.ui.notify("OM compaction completed", "info"),
555
+ onError: error => ctx.ui.notify(`OM compaction failed: ${error.message}`, "error"),
556
+ });
557
+ },
558
+ });
559
+ }
560
+
561
+ async function safeBoundary(ctx: any, boundary: string): Promise<void> {
562
+ const state = await ensureState(ctx);
563
+ if (!state.enabled || state.status === "failed") return;
564
+ await refreshCounts(ctx);
565
+ updateStatus(ctx);
566
+ if (runtime.currentOperation) return;
567
+ if (state.pendingMessageTokens >= state.thresholds.blockAfter) {
568
+ await observeNow(ctx, { force: true, reason: `${boundary}:block-after` });
569
+ if (state.observationTokens >= state.thresholds.reflection) await reflectNow(ctx, { reason: `${boundary}:threshold` });
570
+ return;
571
+ }
572
+ if (state.pendingMessageTokens >= effectiveObservationThreshold(state)) {
573
+ if (state.buffered.observations.length > 0) await activateBuffered(ctx, `${boundary}:threshold`);
574
+ await observeNow(ctx, { force: false, reason: `${boundary}:threshold` });
575
+ if (state.observationTokens >= state.thresholds.reflection) await reflectNow(ctx, { reason: `${boundary}:threshold` });
576
+ return;
577
+ }
578
+ if (shouldBuffer(state)) {
579
+ void bufferObservation(ctx, `${boundary}:buffer`).catch(async (error) => {
580
+ const s = await ensureState(ctx);
581
+ s.status = "failed";
582
+ s.lastError = `OM buffer failed: ${errorMessage(error)}`;
583
+ await saveState(s);
584
+ updateStatus(ctx);
585
+ ctx.ui.notify(s.lastError, "error");
586
+ });
587
+ }
588
+ }
589
+
590
+ async function ensureState(ctx: any): Promise<PiOMRecord> {
591
+ if (runtime.state && runtime.state.cwd === ctx.cwd && runtime.state.sessionFile === ctx.sessionManager.getSessionFile()) return runtime.state;
592
+ const sessionFile = ctx.sessionManager.getSessionFile?.();
593
+ const sessionId = ctx.sessionManager.getSessionId?.() || (sessionFile ? basename(sessionFile, ".jsonl") : "in-memory");
594
+ const dir = join(ctx.cwd, CONFIG_DIR_NAME, "om");
595
+ const debugDir = join(dir, "debug");
596
+ await mkdir(debugDir, { recursive: true });
597
+ const statePath = join(dir, `${sanitizeFileName(sessionId)}.json`);
598
+ runtime.statePath = statePath;
599
+ runtime.debugDir = debugDir;
600
+ let state: PiOMRecord | undefined;
601
+ if (existsSync(statePath)) {
602
+ try {
603
+ state = JSON.parse(await readFile(statePath, "utf8")) as PiOMRecord;
604
+ } catch (error) {
605
+ throw new Error(`Failed to load OM state ${statePath}: ${errorMessage(error)}`);
606
+ }
607
+ }
608
+ if (!state || state.version !== STATE_VERSION) {
609
+ state = {
610
+ version: STATE_VERSION,
611
+ enabled: true,
612
+ sessionId,
613
+ sessionFile,
614
+ cwd: ctx.cwd,
615
+ scope: "session",
616
+ status: "idle",
617
+ observations: "",
618
+ pendingMessageTokens: 0,
619
+ observationTokens: 0,
620
+ thresholds: {
621
+ observation: OBSERVATION_THRESHOLD,
622
+ reflection: REFLECTION_THRESHOLD,
623
+ blockAfter: Math.round(OBSERVATION_THRESHOLD * DEFAULT_BLOCK_AFTER_MULTIPLIER),
624
+ bufferTokens: Math.round(OBSERVATION_THRESHOLD * BUFFER_TOKENS_RATIO),
625
+ bufferActivation: BUFFER_ACTIVATION_RATIO,
626
+ },
627
+ buffered: { observations: [] },
628
+ updatedAt: new Date().toISOString(),
629
+ };
630
+ await saveState(state);
631
+ }
632
+ runtime.state = state;
633
+ return state;
634
+ }
635
+
636
+ async function saveState(state: PiOMRecord): Promise<void> {
637
+ if (!runtime.statePath) return;
638
+ state.updatedAt = new Date().toISOString();
639
+ await mkdir(dirname(runtime.statePath), { recursive: true });
640
+ await writeFile(runtime.statePath, JSON.stringify(state, null, 2) + "\n", "utf8");
641
+ runtime.overlayHandle?.requestRender();
642
+ }
643
+
644
+ async function refreshCounts(ctx: any): Promise<void> {
645
+ const state = await ensureState(ctx);
646
+ const branch = ctx.sessionManager.getBranch() as SessionEntry[];
647
+ const pending = entriesAfter(branch, state.lastObservedEntryId).filter(isMessageLikeEntry);
648
+ state.pendingMessageTokens = estimateEntriesTokens(pending);
649
+ state.observationTokens = estimateTokens(state.observations);
650
+ await saveState(state);
651
+ }
652
+
653
+ function updateStatus(ctx: any): void {
654
+ const state = runtime.state;
655
+ if (!state) return;
656
+ ctx.ui.setStatus("om", formatShortStatusColored(state));
657
+ runtime.overlayHandle?.requestRender();
658
+ }
659
+
660
+ async function observeNow(ctx: any, opts: { force: boolean; reason: string; signal?: AbortSignal; manualText?: string }): Promise<void> {
661
+ const state = await ensureState(ctx);
662
+ assertNoOperation(state);
663
+ const branch = ctx.sessionManager.getBranch() as SessionEntry[];
664
+ const candidates = entriesAfter(branch, state.lastObservedEntryId).filter(isMessageLikeEntry);
665
+ if (candidates.length === 0 && !opts.manualText) return;
666
+ const selected = opts.force ? candidates : selectEntriesForObservation(candidates, state);
667
+ if (selected.length === 0 && !opts.manualText) return;
668
+ const inputText = opts.manualText ?? formatEntriesForObserver(selected);
669
+ const startedAt = new Date().toISOString();
670
+ state.operationLock = { type: "observation", startedAt };
671
+ state.status = "observing";
672
+ await saveState(state);
673
+ updateStatus(ctx);
674
+ const controller = new AbortController();
675
+ runtime.currentOperation = controller;
676
+ const signal = mergeAbortSignals(opts.signal, controller.signal);
677
+ try {
678
+ const result = await runObserver(ctx, inputText, signal, { source: opts.reason, existingObservations: state.observations });
679
+ appendObservations(state, result, estimateTokens(inputText));
680
+ const last = selected.at(-1);
681
+ if (last?.id) state.lastObservedEntryId = last.id;
682
+ state.pendingMessageTokens = Math.max(0, state.pendingMessageTokens - estimateEntriesTokens(selected));
683
+ state.operationLock = undefined;
684
+ state.status = "idle";
685
+ state.lastError = undefined;
686
+ await writeDebug(ctx, "observation", { startedAt, reason: opts.reason, inputText, rawOutput: result.rawOutput, parsed: result });
687
+ await saveState(state);
688
+ } catch (error) {
689
+ state.operationLock = undefined;
690
+ state.status = "failed";
691
+ state.lastError = `Observational Memory Observer failed: ${errorMessage(error)}`;
692
+ state.lastOperation = { type: "observation", startedAt, endedAt: new Date().toISOString(), inputTokens: estimateTokens(inputText), error: state.lastError };
693
+ await saveState(state);
694
+ throw new Error(state.lastError);
695
+ } finally {
696
+ runtime.currentOperation = undefined;
697
+ updateStatus(ctx);
698
+ }
699
+ }
700
+
701
+ async function bufferObservation(ctx: any, reason: string): Promise<void> {
702
+ const state = await ensureState(ctx);
703
+ if (runtime.currentOperation || state.operationLock) return;
704
+ const branch = ctx.sessionManager.getBranch() as SessionEntry[];
705
+ const lastBuffered = state.buffered.observations.at(-1)?.endEntryId ?? state.lastObservedEntryId;
706
+ const candidates = entriesAfter(branch, lastBuffered).filter(isMessageLikeEntry);
707
+ const tokens = estimateEntriesTokens(candidates);
708
+ if (tokens < state.thresholds.bufferTokens) return;
709
+ const selected = takeEntriesUpTo(candidates, Math.min(tokens, OBSERVER_MAX_BATCH_TOKENS));
710
+ if (selected.length === 0) return;
711
+ const inputText = formatEntriesForObserver(selected);
712
+ const startedAt = new Date().toISOString();
713
+ state.operationLock = { type: "buffer", startedAt };
714
+ state.status = "buffering";
715
+ await saveState(state);
716
+ updateStatus(ctx);
717
+ const controller = new AbortController();
718
+ runtime.currentOperation = controller;
719
+ try {
720
+ const result = await runObserver(ctx, inputText, controller.signal, { source: reason, existingObservations: state.observations });
721
+ const observations = result.observations.trim();
722
+ if (!observations) throw new Error("Observer returned empty buffered observations");
723
+ state.buffered.observations.push({
724
+ id: `buf-${Date.now().toString(36)}`,
725
+ startEntryId: selected[0]?.id,
726
+ endEntryId: selected.at(-1)!.id,
727
+ messageTokens: estimateEntriesTokens(selected),
728
+ observationTokens: estimateTokens(observations),
729
+ observations,
730
+ currentTask: result.currentTask,
731
+ suggestedResponse: result.suggestedContinuation,
732
+ createdAt: new Date().toISOString(),
733
+ });
734
+ state.operationLock = undefined;
735
+ state.status = "idle";
736
+ state.lastOperation = { type: "buffer", startedAt, endedAt: new Date().toISOString(), inputTokens: estimateTokens(inputText), outputTokens: estimateTokens(observations), model: DEFAULT_OBSERVATION_MODEL };
737
+ await writeDebug(ctx, "buffer", { startedAt, reason, inputText, rawOutput: result.rawOutput, parsed: result });
738
+ await saveState(state);
739
+ } catch (error) {
740
+ state.operationLock = undefined;
741
+ state.status = "failed";
742
+ state.lastError = `Observational Memory Observer failed: ${errorMessage(error)}`;
743
+ await saveState(state);
744
+ throw error;
745
+ } finally {
746
+ runtime.currentOperation = undefined;
747
+ updateStatus(ctx);
748
+ }
749
+ }
750
+
751
+ async function activateBuffered(ctx: any, reason: string): Promise<void> {
752
+ const state = await ensureState(ctx);
753
+ if (state.buffered.observations.length === 0) return;
754
+ assertNoOperation(state);
755
+ const startedAt = new Date().toISOString();
756
+ state.operationLock = { type: "activation", startedAt };
757
+ state.status = "observing";
758
+ const chunks = state.buffered.observations;
759
+ const activationTarget = state.pendingMessageTokens - state.thresholds.observation * (1 - state.thresholds.bufferActivation);
760
+ let activatedTokens = 0;
761
+ const activated: BufferedChunk[] = [];
762
+ for (const chunk of chunks) {
763
+ if (activatedTokens < activationTarget || activated.length === 0) {
764
+ activated.push(chunk);
765
+ activatedTokens += chunk.messageTokens;
766
+ }
767
+ }
768
+ const observations = activated.map(c => c.observations).join("\n\n").trim();
769
+ state.observations = [state.observations.trim(), observations].filter(Boolean).join("\n\n");
770
+ const last = activated.at(-1);
771
+ if (last) {
772
+ state.lastObservedEntryId = last.endEntryId;
773
+ state.currentTask = last.currentTask ?? state.currentTask;
774
+ state.suggestedResponse = last.suggestedResponse ?? state.suggestedResponse;
775
+ }
776
+ state.buffered.observations = chunks.slice(activated.length);
777
+ state.pendingMessageTokens = Math.max(0, state.pendingMessageTokens - activatedTokens);
778
+ state.observationTokens = estimateTokens(state.observations);
779
+ state.lastOperation = { type: "activation", startedAt, endedAt: new Date().toISOString(), inputTokens: activatedTokens, outputTokens: estimateTokens(observations) };
780
+ state.operationLock = undefined;
781
+ state.status = "idle";
782
+ await writeDebug(ctx, "activation", { startedAt, reason, activated: activated.map(c => c.id), observations });
783
+ await saveState(state);
784
+ updateStatus(ctx);
785
+ }
786
+
787
+ async function reflectNow(ctx: any, opts: { reason: string; signal?: AbortSignal; manualPrompt?: string }): Promise<void> {
788
+ const state = await ensureState(ctx);
789
+ assertNoOperation(state);
790
+ if (!state.observations.trim()) throw new Error("No observations to reflect");
791
+ const startedAt = new Date().toISOString();
792
+ state.operationLock = { type: "reflection", startedAt };
793
+ state.status = "reflecting";
794
+ await saveState(state);
795
+ updateStatus(ctx);
796
+ const original = state.observations;
797
+ const originalTokens = estimateTokens(original);
798
+ const controller = new AbortController();
799
+ runtime.currentOperation = controller;
800
+ const signal = mergeAbortSignals(opts.signal, controller.signal);
801
+ let lastRaw = "";
802
+ try {
803
+ for (let level = 0 as CompressionLevel; level <= 4; level = (level + 1) as CompressionLevel) {
804
+ const result = await runReflector(ctx, original, level, signal, opts.manualPrompt);
805
+ lastRaw = result.rawOutput;
806
+ if (result.degenerate) throw new Error("Reflector output was degenerate/repetitive");
807
+ if (!result.observations.trim()) throw new Error("Reflector returned empty observations");
808
+ const reflectedTokens = estimateTokens(result.observations);
809
+ const compressedEnough = reflectedTokens < Math.min(originalTokens, state.thresholds.reflection);
810
+ await writeDebug(ctx, `reflection-level-${level}`, { startedAt, reason: opts.reason, originalTokens, reflectedTokens, rawOutput: result.rawOutput, parsed: result });
811
+ if (compressedEnough || level === 4) {
812
+ if (!compressedEnough) throw new Error(`Reflector failed to compress below target after level ${level}: ${reflectedTokens} >= ${Math.min(originalTokens, state.thresholds.reflection)}`);
813
+ state.observations = result.observations;
814
+ state.currentTask = result.currentTask ?? state.currentTask;
815
+ state.suggestedResponse = result.suggestedContinuation ?? state.suggestedResponse;
816
+ state.observationTokens = reflectedTokens;
817
+ state.operationLock = undefined;
818
+ state.status = "idle";
819
+ state.lastError = undefined;
820
+ state.lastOperation = { type: "reflection", startedAt, endedAt: new Date().toISOString(), inputTokens: originalTokens, outputTokens: reflectedTokens, model: DEFAULT_REFLECTION_MODEL, compressionLevel: level };
821
+ await saveState(state);
822
+ return;
823
+ }
824
+ }
825
+ } catch (error) {
826
+ state.operationLock = undefined;
827
+ state.status = "failed";
828
+ state.lastError = `Observational Memory Reflector failed: ${errorMessage(error)}`;
829
+ state.lastOperation = { type: "reflection", startedAt, endedAt: new Date().toISOString(), inputTokens: originalTokens, error: state.lastError };
830
+ await writeDebug(ctx, "reflection-failed", { startedAt, reason: opts.reason, error: state.lastError, lastRaw });
831
+ await saveState(state);
832
+ throw new Error(state.lastError);
833
+ } finally {
834
+ runtime.currentOperation = undefined;
835
+ updateStatus(ctx);
836
+ }
837
+ }
838
+
839
+ async function runObserver(ctx: any, historyText: string, signal: AbortSignal | undefined, opts: { source: string; existingObservations?: string }): Promise<ObserverResult> {
840
+ const model = resolveModel(ctx, DEFAULT_OBSERVATION_MODEL);
841
+ const response = await runModel(ctx, model, [
842
+ { role: "user", content: [{ type: "text", text: buildObserverSystemPrompt() }] },
843
+ { role: "user", content: [{ type: "text", text: `## New Message History to Observe\n\n${historyText}\n\n---\n\n${buildObserverTaskPrompt(opts.existingObservations, { priorCurrentTask: runtime.state?.currentTask, priorSuggestedResponse: runtime.state?.suggestedResponse })}` }] },
844
+ ], { temperature: 0.3, maxTokens: 100_000, signal });
845
+ const text = responseText(response);
846
+ const parsed = parseObserverOutput(text);
847
+ if (parsed.degenerate) throw new Error("Observer output was degenerate/repetitive");
848
+ if (!parsed.observations.trim()) throw new Error("Observer returned no observations");
849
+ return parsed;
850
+ }
851
+
852
+ async function runReflector(ctx: any, observations: string, level: CompressionLevel, signal: AbortSignal | undefined, manualPrompt?: string): Promise<ReflectorResult> {
853
+ const model = resolveModel(ctx, DEFAULT_REFLECTION_MODEL);
854
+ const response = await runModel(ctx, model, [
855
+ { role: "user", content: [{ type: "text", text: buildReflectorSystemPrompt() }] },
856
+ { role: "user", content: [{ type: "text", text: buildReflectorPrompt(observations, level, manualPrompt) }] },
857
+ ], { temperature: 0, maxTokens: 100_000, signal });
858
+ const text = responseText(response);
859
+ return parseReflectorOutput(text);
860
+ }
861
+
862
+ async function runModel(ctx: any, model: any, messages: any[], opts: { temperature: number; maxTokens: number; signal?: AbortSignal }): Promise<any> {
863
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
864
+ if (!auth.ok) throw new Error(`Auth failed for ${model.provider}/${model.id}: ${auth.error}`);
865
+ if (!auth.apiKey) throw new Error(`No API key for ${model.provider}/${model.id}`);
866
+ return complete(model, { messages } as any, { apiKey: auth.apiKey, headers: auth.headers, maxTokens: opts.maxTokens, temperature: opts.temperature, signal: opts.signal } as any);
867
+ }
868
+
869
+ function resolveModel(ctx: any, modelId: string): any {
870
+ const slash = modelId.indexOf("/");
871
+ const provider = slash >= 0 ? modelId.slice(0, slash) : ctx.model?.provider;
872
+ const id = slash >= 0 ? modelId.slice(slash + 1) : modelId;
873
+ const model = ctx.modelRegistry.find(provider, id);
874
+ if (!model) throw new Error(`Could not find OM model ${provider}/${id}`);
875
+ return model;
876
+ }
877
+
878
+ function parseObserverOutput(output: string): ObserverResult {
879
+ if (detectDegenerateRepetition(output)) return { observations: "", rawOutput: output, degenerate: true };
880
+ const parsed = parseMemorySectionXml(output);
881
+ return { observations: sanitizeObservationLines(parsed.observations), currentTask: parsed.currentTask || undefined, suggestedContinuation: parsed.suggestedResponse || undefined, rawOutput: output };
882
+ }
883
+
884
+ function parseReflectorOutput(output: string): ReflectorResult {
885
+ if (detectDegenerateRepetition(output)) return { observations: "", rawOutput: output, degenerate: true };
886
+ const parsed = parseMemorySectionXml(output);
887
+ return { observations: sanitizeObservationLines(parsed.observations), currentTask: parsed.currentTask || undefined, suggestedContinuation: parsed.suggestedResponse || undefined, rawOutput: output };
888
+ }
889
+
890
+ function parseMemorySectionXml(content: string): { observations: string; currentTask: string; suggestedResponse: string } {
891
+ const observationsMatches = [...content.matchAll(/^[ \t]*<observations>([\s\S]*?)^[ \t]*<\/observations>/gim)];
892
+ const observations = observationsMatches.length > 0 ? observationsMatches.map(m => m[1]?.trim() ?? "").filter(Boolean).join("\n") : extractListItemsOnly(content);
893
+ const currentTask = content.match(/^[ \t]*<current-task>([\s\S]*?)^[ \t]*<\/current-task>/im)?.[1]?.trim() ?? "";
894
+ const suggestedResponse = content.match(/^[ \t]*<suggested-response>([\s\S]*?)^[ \t]*<\/suggested-response>/im)?.[1]?.trim() ?? "";
895
+ return { observations, currentTask, suggestedResponse };
896
+ }
897
+
898
+ function extractListItemsOnly(content: string): string {
899
+ return content.split("\n").filter(line => /^\s*[-*]\s/.test(line) || /^\s*\d+\.\s/.test(line) || /^\s*Date:\s/i.test(line)).join("\n").trim();
900
+ }
901
+
902
+ function sanitizeObservationLines(observations: string): string {
903
+ return observations.split("\n").map(line => line.length > MAX_OBSERVATION_LINE_CHARS ? `${line.slice(0, MAX_OBSERVATION_LINE_CHARS)} … [truncated]` : line).join("\n").trim();
904
+ }
905
+
906
+ function detectDegenerateRepetition(text: string): boolean {
907
+ if (!text || text.length < 2000) return false;
908
+ const windowSize = 200;
909
+ const step = Math.max(1, Math.floor(text.length / 50));
910
+ const seen = new Map<string, number>();
911
+ let duplicates = 0;
912
+ let total = 0;
913
+ for (let i = 0; i + windowSize <= text.length; i += step) {
914
+ const window = text.slice(i, i + windowSize);
915
+ total++;
916
+ const count = (seen.get(window) ?? 0) + 1;
917
+ seen.set(window, count);
918
+ if (count > 1) duplicates++;
919
+ }
920
+ if (total > 5 && duplicates / total > 0.4) return true;
921
+ return text.split("\n").some(line => line.length > 50_000);
922
+ }
923
+
924
+ function appendObservations(state: PiOMRecord, result: ObserverResult, inputTokens: number): void {
925
+ const observations = result.observations.trim();
926
+ if (!observations) throw new Error("No observations to append");
927
+ state.observations = [state.observations.trim(), observations].filter(Boolean).join("\n\n");
928
+ state.currentTask = result.currentTask ?? state.currentTask;
929
+ state.suggestedResponse = result.suggestedContinuation ?? state.suggestedResponse;
930
+ state.observationTokens = estimateTokens(state.observations);
931
+ state.lastOperation = { type: "observation", startedAt: state.operationLock?.startedAt ?? new Date().toISOString(), endedAt: new Date().toISOString(), inputTokens, outputTokens: estimateTokens(observations), model: DEFAULT_OBSERVATION_MODEL };
932
+ }
933
+
934
+ function buildOMContextMessage(state: PiOMRecord): AgentMessage {
935
+ const sections = [
936
+ OBSERVATION_CONTEXT_PROMPT,
937
+ `<observations>\n${state.observations.trim()}\n</observations>`,
938
+ OBSERVATION_CONTEXT_INSTRUCTIONS,
939
+ state.currentTask ? `<current-task>\n${state.currentTask}\n</current-task>` : "",
940
+ state.suggestedResponse ? `<suggested-response>\n${state.suggestedResponse}\n</suggested-response>` : "",
941
+ OBSERVATION_RETRIEVAL_INSTRUCTIONS,
942
+ `<system-reminder>\n${OBSERVATION_CONTINUATION_HINT}\n</system-reminder>`,
943
+ ].filter(Boolean);
944
+ return { role: "custom", customType: EXTENSION_ID, display: false, content: sections.join("\n\n"), timestamp: Date.now() };
945
+ }
946
+
947
+ function buildCompactionSummary(state: PiOMRecord): string {
948
+ return [
949
+ "## Observational Memory",
950
+ "Pi legacy summary compaction was replaced by Mastra-style Observational Memory.",
951
+ "",
952
+ "The assistant should use the observations below as memory of prior conversation. Recent raw messages after the compaction boundary remain in context.",
953
+ "",
954
+ "<observations>",
955
+ state.observations.trim(),
956
+ "</observations>",
957
+ state.currentTask ? `\n<current-task>\n${state.currentTask}\n</current-task>` : "",
958
+ state.suggestedResponse ? `\n<suggested-response>\n${state.suggestedResponse}\n</suggested-response>` : "",
959
+ "",
960
+ OBSERVATION_CONTEXT_INSTRUCTIONS,
961
+ ].filter(Boolean).join("\n");
962
+ }
963
+
964
+ function snapshotDetails(state: PiOMRecord): Record<string, unknown> {
965
+ return { observationTokens: state.observationTokens, pendingMessageTokens: state.pendingMessageTokens, lastObservedEntryId: state.lastObservedEntryId, currentTask: state.currentTask, suggestedResponse: state.suggestedResponse, thresholds: state.thresholds, lastOperation: state.lastOperation };
966
+ }
967
+
968
+ function effectiveObservationThreshold(state: PiOMRecord): number {
969
+ const total = state.thresholds.observation + state.thresholds.reflection;
970
+ return Math.max(state.thresholds.observation, total - state.observationTokens);
971
+ }
972
+
973
+ function shouldBuffer(state: PiOMRecord): boolean {
974
+ if (state.buffered.observations.length === 0) return state.pendingMessageTokens >= state.thresholds.bufferTokens;
975
+ const bufferedTokens = state.buffered.observations.reduce((sum, c) => sum + c.messageTokens, 0);
976
+ return state.pendingMessageTokens - bufferedTokens >= state.thresholds.bufferTokens;
977
+ }
978
+
979
+ function assertNoOperation(state: PiOMRecord): void {
980
+ if (state.operationLock) throw new Error(`OM operation already active: ${state.operationLock.type} since ${state.operationLock.startedAt}`);
981
+ }
982
+
983
+ function selectEntriesForObservation(entries: SessionEntry[], state: PiOMRecord): SessionEntry[] {
984
+ const retentionFloor = state.thresholds.observation * (1 - state.thresholds.bufferActivation);
985
+ const target = Math.max(0, state.pendingMessageTokens - retentionFloor);
986
+ if (target <= 0) return [];
987
+ return takeEntriesUpTo(entries, Math.min(target, OBSERVER_MAX_BATCH_TOKENS));
988
+ }
989
+
990
+ function takeEntriesUpTo(entries: SessionEntry[], targetTokens: number): SessionEntry[] {
991
+ const selected: SessionEntry[] = [];
992
+ let total = 0;
993
+ for (const entry of entries) {
994
+ selected.push(entry);
995
+ total += estimateEntryTokens(entry);
996
+ if (total >= targetTokens) break;
997
+ }
998
+ return selected;
999
+ }
1000
+
1001
+ function entriesAfter(branch: SessionEntry[], id?: string): SessionEntry[] {
1002
+ if (!id) return branch;
1003
+ const idx = branch.findIndex(e => e.id === id);
1004
+ return idx >= 0 ? branch.slice(idx + 1) : branch;
1005
+ }
1006
+
1007
+ function entriesBefore(branch: SessionEntry[], id?: string): SessionEntry[] {
1008
+ if (!id) return branch;
1009
+ const idx = branch.findIndex(e => e.id === id);
1010
+ return idx >= 0 ? branch.slice(0, idx) : branch;
1011
+ }
1012
+
1013
+ function isMessageLikeEntry(entry: SessionEntry): boolean {
1014
+ return entry.type === "message" || entry.type === "custom_message" || entry.type === "compaction" || entry.type === "branch_summary";
1015
+ }
1016
+
1017
+ function entryToAgentMessage(entry: SessionEntry): AgentMessage | undefined {
1018
+ if (entry.type === "message") return entry.message;
1019
+ if (entry.type === "custom_message") return { role: "custom", customType: entry.customType, content: entry.content, display: entry.display, details: entry.details, timestamp: Date.parse(entry.timestamp) || Date.now() };
1020
+ if (entry.type === "compaction") return { role: "compactionSummary", summary: entry.summary, tokensBefore: entry.tokensBefore, timestamp: Date.parse(entry.timestamp) || Date.now() };
1021
+ if (entry.type === "branch_summary") return { role: "branchSummary", summary: entry.summary, fromId: entry.fromId, timestamp: Date.parse(entry.timestamp) || Date.now() };
1022
+ return undefined;
1023
+ }
1024
+
1025
+ function formatEntriesForObserver(entries: SessionEntry[]): string {
1026
+ return entries.map(formatEntryForObserver).filter(Boolean).join("\n\n");
1027
+ }
1028
+
1029
+ function formatEntryForObserver(entry: SessionEntry): string {
1030
+ const msg = entryToAgentMessage(entry);
1031
+ const createdAt = new Date(entry.timestamp || msg?.timestamp || Date.now());
1032
+ const date = createdAt.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
1033
+ const time = createdAt.toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false });
1034
+ const body = formatAgentMessage(msg, "observer");
1035
+ return body ? `${date}:\n[entry ${entry.id}] ${time} ${body}` : "";
1036
+ }
1037
+
1038
+ function formatAgentMessage(msg: any, mode: "observer" | "recall" = "observer", detail: "low" | "high" = "low"): string {
1039
+ if (!msg) return "";
1040
+ const cap = mode === "observer" ? MESSAGE_PART_MAX_CHARS : detail === "high" ? 60_000 : 4_000;
1041
+ switch (msg.role) {
1042
+ case "user": return `User: ${formatContent(msg.content, cap)}`;
1043
+ case "assistant": return `Assistant: ${formatAssistantContent(msg.content, cap)}`;
1044
+ case "toolResult": return `Tool Result ${msg.toolName}: ${truncateText(formatContent(msg.content, cap), detail === "high" ? 60_000 : TOOL_RESULT_MAX_CHARS)}`;
1045
+ case "bashExecution": return `User Bash: ${msg.command}\nOutput: ${truncateText(msg.output ?? "", cap)}${msg.truncated ? "\n[output truncated by Pi]" : ""}`;
1046
+ case "custom": return msg.customType === EXTENSION_ID ? "" : `Custom ${msg.customType}: ${formatContent(msg.content, cap)}`;
1047
+ case "compactionSummary": return `Previous Compaction Summary: ${truncateText(msg.summary ?? "", cap)}`;
1048
+ case "branchSummary": return `Branch Summary: ${truncateText(msg.summary ?? "", cap)}`;
1049
+ default: return `${msg.role ?? "Message"}: ${truncateText(JSON.stringify(msg), cap)}`;
1050
+ }
1051
+ }
1052
+
1053
+ function formatContent(content: any, cap: number): string {
1054
+ if (typeof content === "string") return truncateText(content, cap);
1055
+ if (!Array.isArray(content)) return truncateText(JSON.stringify(content), cap);
1056
+ return content.map(part => {
1057
+ if (part.type === "text") return part.text;
1058
+ if (part.type === "thinking") return `[Thinking]: ${part.thinking}`;
1059
+ if (part.type === "image") return `[Image: ${part.mimeType ?? "unknown"}]`;
1060
+ if (part.type === "toolCall") return `[Tool Call ${part.name}: ${truncateText(JSON.stringify(part.arguments ?? {}), 2_000)}]`;
1061
+ return `[${part.type}: ${truncateText(JSON.stringify(part), 2_000)}]`;
1062
+ }).join("\n").slice(0, cap);
1063
+ }
1064
+
1065
+ function formatAssistantContent(content: any, cap: number): string {
1066
+ return formatContent(content, cap);
1067
+ }
1068
+
1069
+ function serializeEntryForRecall(entry: SessionEntry, detail: "low" | "high"): string {
1070
+ return `[${entry.id}] ${entry.type} ${entry.timestamp}\n${formatAgentMessage(entryToAgentMessage(entry), "recall", detail)}`;
1071
+ }
1072
+
1073
+ function estimateEntriesTokens(entries: SessionEntry[]): number {
1074
+ return entries.reduce((sum, e) => sum + estimateEntryTokens(e), 0);
1075
+ }
1076
+
1077
+ function estimateMessagesTokens(messages: any[]): number {
1078
+ return estimateTokens(JSON.stringify(messages));
1079
+ }
1080
+
1081
+ function estimateEntryTokens(entry: SessionEntry): number {
1082
+ return estimateTokens(formatEntryForObserver(entry) || JSON.stringify(entry));
1083
+ }
1084
+
1085
+ function estimateTokens(text: string | undefined): number {
1086
+ if (!text) return 0;
1087
+ return Math.ceil(text.length / 4);
1088
+ }
1089
+
1090
+ function truncateText(text: string, maxChars: number): string {
1091
+ if (text.length <= maxChars) return text;
1092
+ return `${text.slice(0, maxChars)}\n... [truncated ${text.length - maxChars} characters]`;
1093
+ }
1094
+
1095
+ function responseText(response: any): string {
1096
+ if (typeof response?.text === "string") return response.text;
1097
+ if (Array.isArray(response?.content)) return response.content.filter((c: any) => c?.type === "text").map((c: any) => c.text).join("\n");
1098
+ if (typeof response === "string") return response;
1099
+ return "";
1100
+ }
1101
+
1102
+ function formatShortStatus(state: PiOMRecord): string {
1103
+ if (!state.enabled) return "om: disabled";
1104
+ const msgPct = Math.min(999, Math.round((state.pendingMessageTokens / effectiveObservationThreshold(state)) * 100));
1105
+ const memPct = Math.min(999, Math.round((state.observationTokens / state.thresholds.reflection) * 100));
1106
+ const buffer = state.buffered.observations.length ? ` | buf ${state.buffered.observations.length} ↓${formatTokens(state.buffered.observations.reduce((s, c) => s + c.messageTokens, 0))}` : "";
1107
+ const err = state.status === "failed" && state.lastError ? ` | ${truncateText(state.lastError, 50).replace(/\n/g, " ")}` : "";
1108
+ return `om: ${state.status} | msg ${formatTokens(state.pendingMessageTokens)}/${formatTokens(effectiveObservationThreshold(state))} ${msgPct}% | mem ${formatTokens(state.observationTokens)}/${formatTokens(state.thresholds.reflection)} ${memPct}%${buffer}${err}`;
1109
+ }
1110
+
1111
+ function formatShortStatusColored(state: PiOMRecord): string {
1112
+ if (!state.enabled) return "\x1b[2;31mom: disabled\x1b[0m";
1113
+
1114
+ const msgPct = Math.min(999, Math.round((state.pendingMessageTokens / effectiveObservationThreshold(state)) * 100));
1115
+ const memPct = Math.min(999, Math.round((state.observationTokens / state.thresholds.reflection) * 100));
1116
+
1117
+ let statusColor = "\x1b[32m"; // green for idle
1118
+ if (state.status === "failed") statusColor = "\x1b[91m";
1119
+ else if (state.status !== "idle") statusColor = "\x1b[93m"; // yellow for active ops
1120
+
1121
+ // Highlight percentages
1122
+ const msgPctStr = msgPct >= 80 ? `\x1b[1;91m${msgPct}%\x1b[0m` : msgPct >= 50 ? `\x1b[1;93m${msgPct}%\x1b[0m` : `\x1b[1;32m${msgPct}%\x1b[0m`;
1123
+ const memPctStr = memPct >= 80 ? `\x1b[1;91m${memPct}%\x1b[0m` : memPct >= 50 ? `\x1b[1;93m${memPct}%\x1b[0m` : `\x1b[1;32m${memPct}%\x1b[0m`;
1124
+
1125
+ const buffer = state.buffered.observations.length
1126
+ ? ` \x1b[2;37m|\x1b[0m buf \x1b[1;36m${state.buffered.observations.length}\x1b[0m \x1b[36m↓${formatTokens(state.buffered.observations.reduce((s, c) => s + c.messageTokens, 0))}\x1b[0m`
1127
+ : "";
1128
+
1129
+ const err = state.status === "failed" && state.lastError
1130
+ ? ` \x1b[2;37m|\x1b[0m \x1b[91m${truncateText(state.lastError, 50).replace(/\n/g, " ")}\x1b[0m`
1131
+ : "";
1132
+
1133
+ return `om: ${statusColor}${state.status}\x1b[0m \x1b[2;37m|\x1b[0m msg \x1b[1;33m${formatTokens(state.pendingMessageTokens)}\x1b[0m/\x1b[2;37m${formatTokens(effectiveObservationThreshold(state))}\x1b[0m [${msgPctStr}] \x1b[2;37m|\x1b[0m mem \x1b[1;36m${formatTokens(state.observationTokens)}\x1b[0m/\x1b[2;37m${formatTokens(state.thresholds.reflection)}\x1b[0m [${memPctStr}]${buffer}${err}`;
1134
+ }
1135
+
1136
+ function formatDetailedStatus(state: PiOMRecord): string {
1137
+ return `${formatShortStatus(state)}\nlastObservedEntryId: ${state.lastObservedEntryId ?? "none"}\ncurrentTask: ${state.currentTask ?? "none"}\nsuggestedResponse: ${state.suggestedResponse ?? "none"}\nlastOperation: ${state.lastOperation ? JSON.stringify(state.lastOperation, null, 2) : "none"}\nstatePath: ${runtime.statePath ?? "unknown"}`;
1138
+ }
1139
+
1140
+ function formatMemoryText(state: PiOMRecord): string {
1141
+ return [`# Observational Memory`, formatShortStatus(state), ``, `## Current Task`, state.currentTask ?? "", ``, `## Suggested Response`, state.suggestedResponse ?? "", ``, `## Observations`, state.observations || "No observations yet.", ``, state.lastError ? `## Last Error\n${state.lastError}` : ""].join("\n");
1142
+ }
1143
+
1144
+ function formatDetailedStatusColored(state: PiOMRecord): string[] {
1145
+ const lines: string[] = [];
1146
+ lines.push(`\x1b[1;36m█ SYSTEM STATUS\x1b[0m`);
1147
+ lines.push(formatShortStatusColored(state));
1148
+ lines.push(``);
1149
+ lines.push(`\x1b[1;33mSession ID:\x1b[0m \x1b[37m${state.sessionId}\x1b[0m`);
1150
+ lines.push(`\x1b[1;33mLast Observed Entry ID:\x1b[0m \x1b[2;37m${state.lastObservedEntryId ?? "none"}\x1b[0m`);
1151
+ lines.push(``);
1152
+ lines.push(`\x1b[1;35mThresholds:\x1b[0m`);
1153
+ lines.push(` - Observation: \x1b[1;32m${formatTokens(state.thresholds.observation)}\x1b[0m tokens`);
1154
+ lines.push(` - Reflection: \x1b[1;32m${formatTokens(state.thresholds.reflection)}\x1b[0m tokens`);
1155
+ lines.push(` - Block After: \x1b[1;32m${formatTokens(state.thresholds.blockAfter)}\x1b[0m tokens`);
1156
+ lines.push(` - Buffer Size: \x1b[1;32m${formatTokens(state.thresholds.bufferTokens)}\x1b[0m tokens`);
1157
+ lines.push(``);
1158
+ lines.push(`\x1b[1;35mLast Operation:\x1b[0m`);
1159
+ if (state.lastOperation) {
1160
+ const op = state.lastOperation;
1161
+ lines.push(` - Type: \x1b[1;36m${op.type}\x1b[0m`);
1162
+ lines.push(` - Model: \x1b[2;37m${op.model ?? "unknown"}\x1b[0m`);
1163
+ lines.push(` - Timing: \x1b[2;37m${op.startedAt} → ${op.endedAt ?? "running"}\x1b[0m`);
1164
+ lines.push(` - Input Tokens: \x1b[1;33m${op.inputTokens}\x1b[0m`);
1165
+ if (op.outputTokens) lines.push(` - Output Tokens: \x1b[1;32m${op.outputTokens}\x1b[0m`);
1166
+ if (op.compressionLevel !== undefined) lines.push(` - Compression Level: \x1b[1;33m${op.compressionLevel}\x1b[0m`);
1167
+ if (op.error) lines.push(` - Error: \x1b[91m${op.error}\x1b[0m`);
1168
+ } else {
1169
+ lines.push(` \x1b[2;37mNo operation executed yet.\x1b[0m`);
1170
+ }
1171
+ lines.push(``);
1172
+ lines.push(`\x1b[1;33mState File:\x1b[0m \x1b[2;37m${runtime.statePath ?? "unknown"}\x1b[0m`);
1173
+ return lines;
1174
+ }
1175
+
1176
+ function highlightObservations(text: string): string[] {
1177
+ const lines = text.split("\n");
1178
+ return lines.map(line => {
1179
+ // 1. Highlight Date
1180
+ if (line.startsWith("Date: ")) {
1181
+ const dateVal = line.slice(6).trim();
1182
+ return `\x1b[1;35m📅 ${dateVal}\x1b[0m`;
1183
+ }
1184
+
1185
+ // 2. Match Bullet points
1186
+ // Format: * 🔴 (14:30) User stated X
1187
+ const bulletMatch = line.match(/^(\s*)\*\s*(🔴|🟡|🟢|✅)?\s*(\(\d{2}:\d{2}\))?\s*(.*)$/);
1188
+ if (bulletMatch) {
1189
+ const [_, indent, emoji, time, rest] = bulletMatch;
1190
+ let colorReset = "\x1b[0m";
1191
+ let bulletColor = "";
1192
+ let timeColor = "";
1193
+ let textStyle = "";
1194
+
1195
+ if (emoji === "🔴") {
1196
+ bulletColor = "\x1b[91m🔴\x1b[0m";
1197
+ timeColor = time ? `\x1b[1;91m${time}\x1b[0m` : "";
1198
+ textStyle = "\x1b[37m"; // Bright White/Normal for text
1199
+ } else if (emoji === "🟡") {
1200
+ bulletColor = "\x1b[93m🟡\x1b[0m";
1201
+ timeColor = time ? `\x1b[1;93m${time}\x1b[0m` : "";
1202
+ textStyle = "\x1b[37m";
1203
+ } else if (emoji === "🟢") {
1204
+ bulletColor = "\x1b[32m🟢\x1b[0m";
1205
+ timeColor = time ? `\x1b[2;32m${time}\x1b[0m` : "";
1206
+ textStyle = "\x1b[2;37m"; // Dim text for low priority
1207
+ } else if (emoji === "✅") {
1208
+ bulletColor = "\x1b[92m✅\x1b[0m";
1209
+ timeColor = time ? `\x1b[1;92m${time}\x1b[0m` : "";
1210
+ textStyle = "\x1b[32m"; // Green text for completed
1211
+ } else {
1212
+ bulletColor = "\x1b[2;36m•\x1b[0m";
1213
+ timeColor = time ? `\x1b[2;37m${time}\x1b[0m` : "";
1214
+ textStyle = "\x1b[0m";
1215
+ }
1216
+
1217
+ let content = rest || "";
1218
+ // Highlight sub-bullets or arrows
1219
+ if (content.startsWith("->")) {
1220
+ content = `\x1b[2;36m└──\x1b[0m ` + content.slice(2).trim();
1221
+ }
1222
+
1223
+ // Highlight key terms: User stated, User requested, User asked, Agent, etc.
1224
+ content = content
1225
+ .replace(/(User stated|User requested|User asked)/gi, "\x1b[1;33m$1\x1b[0m" + textStyle)
1226
+ .replace(/(Agent|Assistant|Observer|Reflector)/gi, "\x1b[1;36m$1\x1b[0m" + textStyle)
1227
+ .replace(/(failed|error|crash)/gi, "\x1b[1;31m$1\x1b[0m" + textStyle)
1228
+ .replace(/(success|resolved|complete|passed)/gi, "\x1b[1;32m$1\x1b[0m" + textStyle);
1229
+
1230
+ return `${indent}${bulletColor} ${timeColor} ${textStyle}${content}\x1b[0m`;
1231
+ }
1232
+
1233
+ return line;
1234
+ });
1235
+ }
1236
+
1237
+ function formatMemoryTextColored(state: PiOMRecord): string[] {
1238
+ const lines: string[] = [];
1239
+
1240
+ // Header
1241
+ lines.push(`\x1b[1;36m█ OBSERVATIONAL MEMORY\x1b[0m`);
1242
+ lines.push(formatShortStatusColored(state));
1243
+ lines.push(``);
1244
+
1245
+ // Current Task
1246
+ lines.push(`\x1b[1;93m◆ CURRENT TASK\x1b[0m`);
1247
+ if (state.currentTask) {
1248
+ lines.push(`\x1b[37m${state.currentTask}\x1b[0m`);
1249
+ } else {
1250
+ lines.push(`\x1b[2;37mNo current task specified.\x1b[0m`);
1251
+ }
1252
+ lines.push(``);
1253
+
1254
+ // Suggested Response
1255
+ lines.push(`\x1b[1;92m◆ SUGGESTED RESPONSE\x1b[0m`);
1256
+ if (state.suggestedResponse) {
1257
+ lines.push(`\x1b[37m${state.suggestedResponse}\x1b[0m`);
1258
+ } else {
1259
+ lines.push(`\x1b[2;37mNo suggested response.\x1b[0m`);
1260
+ }
1261
+ lines.push(``);
1262
+
1263
+ // Observations
1264
+ lines.push(`\x1b[1;96m◆ OBSERVATIONS\x1b[0m`);
1265
+ if (state.observations) {
1266
+ lines.push(...highlightObservations(state.observations));
1267
+ } else {
1268
+ lines.push(`\x1b[2;37mNo observations yet.\x1b[0m`);
1269
+ }
1270
+
1271
+ if (state.lastError) {
1272
+ lines.push(``);
1273
+ lines.push(`\x1b[1;91m▲ LAST ERROR\x1b[0m`);
1274
+ lines.push(`\x1b[31m${state.lastError}\x1b[0m`);
1275
+ }
1276
+
1277
+ return lines;
1278
+ }
1279
+
1280
+ function formatDebugTabColored(state: PiOMRecord): string[] {
1281
+ const lines: string[] = [];
1282
+ lines.push(`\x1b[1;31m█ DIAGNOSTICS & BUFFER\x1b[0m`);
1283
+ lines.push(``);
1284
+ lines.push(`\x1b[1;33mState Path:\x1b[0m \x1b[2;37m${runtime.statePath ?? "unknown"}\x1b[0m`);
1285
+ lines.push(`\x1b[1;33mDebug Dir:\x1b[0m \x1b[2;37m${runtime.debugDir ?? "unknown"}\x1b[0m`);
1286
+ lines.push(`\x1b[1;33mLock:\x1b[0m ${state.operationLock ? `\x1b[1;31m${state.operationLock.type}\x1b[0m \x1b[2;37msince ${state.operationLock.startedAt}\x1b[0m` : "\x1b[2;32mnone\x1b[0m"}`);
1287
+ lines.push(``);
1288
+ lines.push(`\x1b[1;35mBuffered Chunks (${state.buffered.observations.length}):\x1b[0m`);
1289
+ if (state.buffered.observations.length > 0) {
1290
+ for (const c of state.buffered.observations) {
1291
+ lines.push(` \x1b[1;36m• ${c.id}\x1b[0m: \x1b[33m${formatTokens(c.messageTokens)} msg\x1b[0m → \x1b[32m${formatTokens(c.observationTokens)} obs\x1b[0m \x1b[2;37m(end ${c.endEntryId})\x1b[0m`);
1292
+ }
1293
+ } else {
1294
+ lines.push(` \x1b[2;37mBuffer is empty.\x1b[0m`);
1295
+ }
1296
+ lines.push(``);
1297
+ if (state.lastError) {
1298
+ lines.push(`\x1b[1;91mlastError:\x1b[0m`);
1299
+ lines.push(`\x1b[31m${state.lastError}\x1b[0m`);
1300
+ } else {
1301
+ lines.push(`\x1b[2;32mNo errors reported.\x1b[0m`);
1302
+ }
1303
+ return lines;
1304
+ }
1305
+
1306
+
1307
+
1308
+ function formatTokens(tokens: number): string {
1309
+ if (tokens >= 1000) return `${(tokens / 1000).toFixed(tokens >= 10_000 ? 0 : 1)}k`;
1310
+ return String(tokens);
1311
+ }
1312
+
1313
+ async function writeDebug(ctx: any, name: string, payload: unknown): Promise<void> {
1314
+ const state = await ensureState(ctx);
1315
+ const dir = runtime.debugDir ?? join(ctx.cwd, CONFIG_DIR_NAME, "om", "debug");
1316
+ await mkdir(dir, { recursive: true });
1317
+ const file = join(dir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${sanitizeFileName(name)}.json`);
1318
+ await writeFile(file, JSON.stringify({ extension: EXTENSION_ID, sessionId: state.sessionId, ...payload as any }, null, 2) + "\n", "utf8");
1319
+ }
1320
+
1321
+ function sanitizeFileName(name: string): string {
1322
+ return name.replace(/[^a-zA-Z0-9._-]+/g, "-").slice(0, 160) || "session";
1323
+ }
1324
+
1325
+ function errorMessage(error: unknown): string {
1326
+ return error instanceof Error ? error.message : String(error);
1327
+ }
1328
+
1329
+ function mergeAbortSignals(a?: AbortSignal, b?: AbortSignal): AbortSignal | undefined {
1330
+ if (!a) return b;
1331
+ if (!b) return a;
1332
+ const controller = new AbortController();
1333
+ const abort = () => controller.abort();
1334
+ if (a.aborted || b.aborted) controller.abort();
1335
+ a.addEventListener("abort", abort, { once: true });
1336
+ b.addEventListener("abort", abort, { once: true });
1337
+ return controller.signal;
1338
+ }
1339
+
1340
+ class OMOverlay {
1341
+ private scroll = 0;
1342
+ private tab: "memory" | "status" | "debug" = "memory";
1343
+ constructor(private getState: () => PiOMRecord | undefined, private close: () => void, private requestRender: () => void = () => {}, private theme?: any) {}
1344
+ invalidate(): void {}
1345
+ handleInput(data: string): void {
1346
+ if (matchesKey(data, Key.escape) || data === "q") return this.close();
1347
+ if (matchesKey(data, Key.left)) this.tab = this.tab === "debug" ? "status" : "memory";
1348
+ else if (matchesKey(data, Key.right)) this.tab = this.tab === "memory" ? "status" : "debug";
1349
+ else if (data === "1") this.tab = "memory";
1350
+ else if (data === "2") this.tab = "status";
1351
+ else if (data === "3") this.tab = "debug";
1352
+ else if (matchesKey(data, Key.up)) this.scroll = Math.max(0, this.scroll - 1);
1353
+ else if (matchesKey(data, Key.down)) this.scroll += 1;
1354
+ else if (matchesKey(data, Key.home)) this.scroll = 0;
1355
+ else if (matchesKey(data, Key.end)) this.scroll = 1_000_000;
1356
+ this.requestRender();
1357
+ }
1358
+ render(width: number): string[] {
1359
+ const state = this.getState();
1360
+ const w = Math.max(20, width);
1361
+ const title = ` Observational Memory — ${this.tab.toUpperCase()} `;
1362
+ const border = "─".repeat(Math.max(0, w - 2));
1363
+ const top = `┌${truncateToWidth(title + border, w - 1, "")}┐`;
1364
+ const bottom = `└${border}┘`;
1365
+
1366
+ // Header tab row
1367
+ const tabMemory = this.tab === "memory" ? "\x1b[1;30;47m MEMORY [1] \x1b[0m" : "\x1b[2;37m MEMORY [1] \x1b[0m";
1368
+ const tabStatus = this.tab === "status" ? "\x1b[1;30;47m STATUS [2] \x1b[0m" : "\x1b[2;37m STATUS [2] \x1b[0m";
1369
+ const tabDebug = this.tab === "debug" ? "\x1b[1;30;47m DIAGNOSTICS [3] \x1b[0m" : "\x1b[2;37m DIAGNOSTICS [3] \x1b[0m";
1370
+ const tabsRow = ` ${tabMemory} │ ${tabStatus} │ ${tabDebug} `;
1371
+
1372
+ const content = state ? this.content(state) : ["No OM state loaded."];
1373
+ const bodyWidth = Math.max(1, w - 4);
1374
+
1375
+ // Add tab row and a visual separator line
1376
+ const fullContent = [
1377
+ tabsRow,
1378
+ `\x1b[2;37m` + "─".repeat(bodyWidth) + `\x1b[0m`,
1379
+ ...content
1380
+ ];
1381
+
1382
+ const wrapped = fullContent.flatMap(line => wrapTextWithAnsi(line || " ", bodyWidth));
1383
+ const maxBody = 28;
1384
+ const maxScroll = Math.max(0, wrapped.length - maxBody);
1385
+ this.scroll = Math.min(this.scroll, maxScroll);
1386
+ const visible = wrapped.slice(this.scroll, this.scroll + maxBody);
1387
+ const lines = [top];
1388
+ for (const line of visible) lines.push(`│ ${truncateToWidth(line, bodyWidth, "").padEnd(bodyWidth)} │`);
1389
+ while (lines.length < maxBody + 1) lines.push(`│ ${" ".repeat(bodyWidth)} │`);
1390
+ const help = "←/→/1-3 tabs • ↑/↓ scroll • home/end • q/esc close";
1391
+ lines.push(`│ ${truncateToWidth(help, bodyWidth, "").padEnd(bodyWidth)} │`);
1392
+ lines.push(bottom);
1393
+ return lines.map(line => truncateToWidth(line, w, ""));
1394
+ }
1395
+ private content(state: PiOMRecord): string[] {
1396
+ if (this.tab === "status") return formatDetailedStatusColored(state);
1397
+ if (this.tab === "debug") return formatDebugTabColored(state);
1398
+ return formatMemoryTextColored(state);
1399
+ }
1400
+ }