pi-observational-memory 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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/package.json +33 -0
  4. package/src/index.ts +575 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-observational-memory contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,67 @@
1
+ # pi-observational-memory
2
+
3
+ Observational memory extension for [Pi](https://github.com/mariozechner/pi). Replaces Pi's default compaction with a two-tier system of **observations** (timestamped event log) and **reflections** (stable long-term facts), giving the agent persistent memory across long conversations.
4
+
5
+ Inspired by [Mastra's Observational Memory](https://mastra.ai/blog/observational-memory#how-it-works) concept. This is an independent implementation adapted for Pi's extension system and compaction model.
6
+
7
+ ## How it works
8
+
9
+ When Pi's context window fills up, this extension intercepts the compaction event and runs two LLM passes:
10
+
11
+ 1. **Observer** — reads recent conversation messages and compresses them into concise, timestamped observations with priority levels (🔴 important, 🟡 maybe important, 🟢 info, ✅ completed).
12
+ 2. **Reflector** — when observations grow past a threshold, promotes stable facts to reflections and prunes dead observations.
13
+
14
+ The resulting `<reflections>` + `<observations>` block becomes the compaction summary that Pi injects at the top of the agent's context. Pi still keeps the most recent raw messages (controlled by `keepRecentTokens`), so the agent sees both structured memory and recent conversation.
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install pi-observational-memory
20
+ ```
21
+
22
+ Then symlink or copy the package into Pi's extensions directory:
23
+
24
+ ```bash
25
+ ln -s $(npm root)/pi-observational-memory ~/.pi/extensions/pi-observational-memory
26
+ ```
27
+
28
+ Pi will discover it on next session start.
29
+
30
+ ## Configuration
31
+
32
+ Create `~/.pi/agent/observational-memory.json`:
33
+
34
+ ```json
35
+ {
36
+ "observationThreshold": 50000,
37
+ "reflectionThreshold": 30000
38
+ }
39
+ ```
40
+
41
+ | Field | Default | Description |
42
+ |-------|---------|-------------|
43
+ | `observationThreshold` | `50000` | Token count that triggers the observer |
44
+ | `reflectionThreshold` | `30000` | Observation token count that triggers the reflector |
45
+ | `compactionModel` | session model | Optional `{ "provider": "...", "id": "..." }` to use a different model for observation/reflection |
46
+
47
+ ### Custom model example
48
+
49
+ ```json
50
+ {
51
+ "observationThreshold": 6000,
52
+ "reflectionThreshold": 1000,
53
+ "compactionModel": { "provider": "openrouter", "id": "google/gemma-4-31b-it" }
54
+ }
55
+ ```
56
+
57
+ ## Commands
58
+
59
+ | Command | Description |
60
+ |---------|-------------|
61
+ | `/om-status` | Show token counts and current thresholds |
62
+ | `/om-view` | Print current reflections and observations |
63
+ | `/om-view --full` | Same as above, plus raw kept messages |
64
+
65
+ ## License
66
+
67
+ MIT
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "pi-observational-memory",
3
+ "version": "0.1.0",
4
+ "description": "Observational memory extension for pi — cache-friendly tiered compaction with observations and reflections.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "elpapi42",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/elpapi42/pi-observational-memory.git"
11
+ },
12
+ "homepage": "https://github.com/elpapi42/pi-observational-memory#readme",
13
+ "keywords": ["pi", "pi-agent", "extension", "observational-memory", "memory", "compaction"],
14
+ "pi": {
15
+ "extensions": ["./src/index.ts"]
16
+ },
17
+ "files": ["src/", "LICENSE", "README.md"],
18
+ "scripts": {
19
+ "typecheck": "tsc --noEmit"
20
+ },
21
+ "peerDependencies": {
22
+ "@mariozechner/pi-coding-agent": "*",
23
+ "@mariozechner/pi-ai": "*",
24
+ "@mariozechner/pi-agent-core": "*"
25
+ },
26
+ "devDependencies": {
27
+ "@mariozechner/pi-ai": "^0.66.1",
28
+ "@mariozechner/pi-agent-core": "^0.66.1",
29
+ "@mariozechner/pi-coding-agent": "^0.66.1",
30
+ "@types/node": "^22.0.0",
31
+ "typescript": "^5.6.0"
32
+ }
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,575 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { completeSimple } from "@mariozechner/pi-ai";
4
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
5
+ import {
6
+ convertToLlm,
7
+ estimateTokens as estimateMessageTokens,
8
+ getAgentDir,
9
+ serializeConversation,
10
+ } from "@mariozechner/pi-coding-agent";
11
+
12
+ // ============================================================================
13
+ // Config
14
+ // ============================================================================
15
+
16
+ interface Config {
17
+ observationThreshold: number;
18
+ reflectionThreshold: number;
19
+ compactionModel?: { provider: string; id: string };
20
+ }
21
+
22
+ const DEFAULTS: Config = {
23
+ observationThreshold: 50_000,
24
+ reflectionThreshold: 30_000,
25
+ };
26
+
27
+ function loadConfig(cwd: string): Config {
28
+ const globalPath = join(getAgentDir(), "observational-memory.json");
29
+ const projectPath = join(cwd, ".pi", "observational-memory.json");
30
+
31
+ let globalConfig: Partial<Config> = {};
32
+ let projectConfig: Partial<Config> = {};
33
+
34
+ if (existsSync(globalPath)) {
35
+ try {
36
+ globalConfig = JSON.parse(readFileSync(globalPath, "utf-8"));
37
+ } catch {}
38
+ }
39
+
40
+ if (existsSync(projectPath)) {
41
+ try {
42
+ projectConfig = JSON.parse(readFileSync(projectPath, "utf-8"));
43
+ } catch {}
44
+ }
45
+
46
+ return { ...DEFAULTS, ...globalConfig, ...projectConfig };
47
+ }
48
+
49
+ // ============================================================================
50
+ // Types
51
+ // ============================================================================
52
+
53
+ interface MemoryState {
54
+ observations: string;
55
+ reflections: string;
56
+ }
57
+
58
+ interface MemoryDetails {
59
+ type: "observational-memory";
60
+ version: 1;
61
+ observations: string;
62
+ reflections: string;
63
+ }
64
+
65
+ // ============================================================================
66
+ // Helpers
67
+ // ============================================================================
68
+
69
+ function isMemoryDetails(d: unknown): d is MemoryDetails {
70
+ return !!d && typeof d === "object" && (d as Record<string, unknown>).type === "observational-memory";
71
+ }
72
+
73
+ function estimateTokens(text: string): number {
74
+ return Math.ceil(text.length / 4);
75
+ }
76
+
77
+ function estimateRawTailTokens(
78
+ entries: Array<{ type: string; message?: unknown; content?: unknown; firstKeptEntryId?: string; id?: string }>,
79
+ ): number {
80
+ let startIndex = 0;
81
+ for (let i = entries.length - 1; i >= 0; i--) {
82
+ if (entries[i].type === "compaction") {
83
+ const keptId = entries[i].firstKeptEntryId;
84
+ if (keptId) {
85
+ for (let j = 0; j < entries.length; j++) {
86
+ if (entries[j].id === keptId) {
87
+ startIndex = j;
88
+ break;
89
+ }
90
+ }
91
+ } else {
92
+ startIndex = i + 1;
93
+ }
94
+ break;
95
+ }
96
+ }
97
+
98
+ let tokens = 0;
99
+ for (let i = startIndex; i < entries.length; i++) {
100
+ const entry = entries[i];
101
+ if (entry.type === "message" && entry.message) {
102
+ tokens += estimateMessageTokens(entry.message as Parameters<typeof estimateMessageTokens>[0]);
103
+ } else if (entry.type === "custom_message" && entry.content) {
104
+ const content = entry.content;
105
+ if (typeof content === "string") {
106
+ tokens += Math.ceil(content.length / 4);
107
+ } else if (Array.isArray(content)) {
108
+ for (const block of content) {
109
+ if (block.type === "text" && block.text) tokens += Math.ceil(block.text.length / 4);
110
+ }
111
+ }
112
+ }
113
+ }
114
+ return tokens;
115
+ }
116
+
117
+ function extractText(response: { content: Array<{ type: string; text?: string }> }): string {
118
+ return response.content
119
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
120
+ .map((c) => c.text)
121
+ .join("\n");
122
+ }
123
+
124
+ // ============================================================================
125
+ // Prompts
126
+ // ============================================================================
127
+
128
+ const OBSERVER_SYSTEM = `You are an observation agent for a coding assistant. Compress conversation messages into concise, timestamped observations.
129
+
130
+ Format as a date-grouped log:
131
+
132
+ Date: YYYY-MM-DD
133
+ - 🔴 HH:MM Observation text
134
+ - 🔴 HH:MM Sub-observation
135
+ - 🟡 HH:MM Sub-observation
136
+ - 🟢 HH:MM Another observation
137
+
138
+ Priority levels:
139
+ - 🔴 Important: user goals, constraints, decisions, names, deadlines, architectural choices, bugs, errors
140
+ - 🟡 Maybe important: questions asked, preferences, approaches considered, configuration details
141
+ - 🟢 Info only: routine operations, minor details
142
+ - ✅ Completed: a task, question, subtask, or issue is concretely resolved
143
+
144
+ CRITICAL — DISTINGUISH USER ASSERTIONS FROM QUESTIONS:
145
+
146
+ When the user TELLS you something about themselves, mark it as an assertion:
147
+ - "I have two kids" → 🔴 User stated has two kids
148
+ - "I work at Acme Corp" → 🔴 User stated works at Acme Corp
149
+
150
+ When the user ASKS about something, mark it as a question/request:
151
+ - "Can you help me with X?" → 🔴 User asked help with X
152
+ - "What's the best way to do Y?" → 🟡 User asked best way to do Y
153
+
154
+ Distinguish between QUESTIONS and STATEMENTS OF INTENT:
155
+ - "Can you recommend..." → Question (extract as "User asked...")
156
+ - "I'm looking forward to doing X" → Statement of intent (extract as "User stated they will do X (include date if mentioned)")
157
+ - "I need to do X" → Statement of intent (extract as "User stated they need to do X")
158
+
159
+ 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.
160
+
161
+ Rules:
162
+ - Group observations by date, with timestamps inline.
163
+ - Use the three-date model when relevant: note the observation date, the referenced date (if the event refers to a different day), and a relative date (e.g. "2 days ago").
164
+ - Nest related sub-observations under a parent observation.
165
+ - Preserve exact file paths, function names, error messages, and technical details.
166
+ - Focus on WHAT happened and WHY, not routine tool calls.
167
+ - Each observation should be one concise line.
168
+
169
+ CONTENT PRESERVATION:
170
+
171
+ User message capture:
172
+ - Short and medium-length user messages: capture nearly verbatim.
173
+ - Very long user messages: summarize but quote key phrases that carry specific intent or meaning.
174
+ - This is critical — when the conversation window shrinks, observations are the only record of what the user said.
175
+
176
+ Preserve unusual phrasing — quote the user's exact words when non-standard:
177
+ - BAD: User exercised.
178
+ - GOOD: User stated they did a "movement session" (their term for exercise).
179
+
180
+ Use precise action verbs — replace vague verbs with specific ones:
181
+ - BAD: User is getting X.
182
+ - GOOD: User subscribed to X. (if context confirms recurring delivery)
183
+ - GOOD: User purchased X. (if context confirms one-time acquisition)
184
+ Common: "getting regularly" → "subscribed to"; "got" → "purchased"/"received"/"was given"; "stopped getting" → "canceled"/"unsubscribed from"
185
+ If the assistant confirms or clarifies the user's vague language, prefer the assistant's more precise terminology.
186
+
187
+ Preserve distinguishing details in assistant-generated content:
188
+ - BAD: Assistant recommended 5 hotels.
189
+ - GOOD: Assistant recommended hotels: Hotel A (near station), Hotel B (budget-friendly), Hotel C (rooftop pool).
190
+ - BAD: Assistant provided social media accounts.
191
+ - GOOD: Assistant provided accounts: @user_one (portraits), @user_two (landscapes).
192
+
193
+ Preserve specific technical/numerical values:
194
+ - BAD: Assistant explained the performance improvements.
195
+ - GOOD: Optimization achieved 43.7% faster load times, memory dropped from 2.8GB to 940MB.
196
+
197
+ Preserve role/participation when user mentions their involvement:
198
+ - BAD: User attended the company event.
199
+ - GOOD: User was a presenter at the company event.
200
+
201
+ Code context — always preserve: exact file paths with line numbers, error messages verbatim, function/variable names, architectural decisions and rationale.
202
+
203
+ STATE CHANGES AND UPDATES:
204
+ When a user indicates they are changing something, frame it as a state change that supersedes previous information:
205
+ - "I'm going to start doing X instead of Y" → "User will start doing X (changing from Y)"
206
+ - "I'm switching from A to B" → "User is switching from A to B"
207
+ - "I moved my stuff to the new place" → "User moved to the new place (no longer at previous location)"
208
+
209
+ If the new state contradicts or updates previous information, make that explicit:
210
+ - BAD: User plans to use the new method.
211
+ - GOOD: User will use the new method (replacing the old approach).
212
+ - Do NOT repeat information already captured in existing reflections or observations.
213
+ - Do NOT wrap output in code blocks or markdown fences.
214
+
215
+ AVOIDING REPETITIVE OBSERVATIONS:
216
+ - Do NOT repeat the same observation across multiple turns if there is no new information.
217
+ - When the agent performs repeated similar actions (e.g., browsing files, running the same tool type multiple times), group them into a single parent observation with sub-bullets for each new result.
218
+
219
+ BAD (repetitive):
220
+ - 🟡 14:30 Agent used view tool on src/auth.ts
221
+ - 🟡 14:31 Agent used view tool on src/users.ts
222
+ - 🟡 14:32 Agent used view tool on src/routes.ts
223
+
224
+ GOOD (grouped):
225
+ - 🟡 14:30 Agent investigated auth flow
226
+ - -> viewed src/auth.ts — found token validation logic
227
+ - -> viewed src/users.ts — found user lookup by email
228
+ - -> viewed src/routes.ts — found middleware chain
229
+
230
+ Only add a new observation for a repeated action if the NEW result changes the picture.
231
+
232
+ COMPLETION TRACKING:
233
+ ✅ markers are explicit memory signals telling the assistant that work is finished and should not be repeated.
234
+
235
+ Use ✅ when:
236
+ - The user explicitly confirms something worked ("thanks, that fixed it", "got it", "perfect")
237
+ - The assistant provided a definitive answer and the user moved on
238
+ - A multi-step task reached its stated goal
239
+ - The user acknowledged receipt of requested information
240
+ - A concrete subtask or implementation step completed during ongoing work
241
+
242
+ Do NOT use ✅ when:
243
+ - The assistant merely responded — the user might follow up with corrections
244
+ - The topic is paused but not resolved ("I'll try that later")
245
+ - The user's reaction is ambiguous
246
+
247
+ Two formats:
248
+ As a sub-bullet under a parent observation:
249
+ - 🔴 HH:MM User asked how to configure auth middleware
250
+ - -> Agent explained JWT setup with code example
251
+ - ✅ User confirmed auth is working
252
+
253
+ Or standalone when closing a broader task:
254
+ - ✅ HH:MM Auth configuration completed — user confirmed middleware is working
255
+
256
+ Completion observations should be terse but specific about WHAT was completed. Prefer concrete resolved outcomes over abstract workflow status.`;
257
+
258
+ const REFLECTOR_SYSTEM = `You are a reflection agent for a coding assistant. Your job is to maintain long-term reflections while preserving as many observations as possible.
259
+
260
+ You will receive current reflections (long-term facts) and accumulated observations.
261
+
262
+ Your task:
263
+ 1. PROMOTE observations to reflections ONLY when they are clearly stable, long-lived facts:
264
+ - User identity, role, preferences
265
+ - Project goals and architecture decisions
266
+ - Permanent constraints and requirements
267
+ - Key technical decisions and their rationale
268
+ After promoting, KEEP the original observation — do not remove it.
269
+ 2. PRUNE observations ONLY when you are certain they are dead:
270
+ - Tasks explicitly completed AND no longer referenced
271
+ - Information directly contradicted or superseded by a newer observation
272
+ - Exact duplicates of other observations
273
+ When in doubt, KEEP the observation.
274
+ IMPORTANT: Preserve ✅ completion markers — they tell the assistant what is already resolved and prevent repeated work. Preserve the concrete resolved outcome captured by ✅ markers. When pruning detailed steps of a completed task, keep the ✅ outcome line.
275
+ USER ASSERTIONS vs QUESTIONS: "User stated: X" = authoritative assertion. "User asked: X" = question/request. When consolidating, USER ASSERTIONS TAKE PRECEDENCE. If you see both "User stated: has two kids" and later "User asked: how many kids?", keep the assertion — the question doesn't invalidate what they told you.
276
+ 3. KEEP everything else. Most observations should survive. An observation being old or low-priority (🟢) is NOT a reason to remove it.
277
+ 4. UPDATE reflections: merge new promoted facts into existing reflections. Remove reflections only if directly contradicted by observations.
278
+
279
+ Output EXACTLY two sections with these tags:
280
+
281
+ <reflections>
282
+ [Updated long-term reflections — stable facts, one per line]
283
+ </reflections>
284
+
285
+ <observations>
286
+ [Surviving observations in the same date-grouped log format — most should be preserved]
287
+ </observations>
288
+
289
+ Do NOT wrap output in code blocks or markdown fences.`;
290
+
291
+ const CONTEXT_USAGE_INSTRUCTIONS = `KNOWLEDGE UPDATES: When observations contain conflicting information, prefer the MOST RECENT observation (check dates). Look for state-change phrases like "will start", "is switching", "changed to", "replacing" as indicators that older information has been superseded.
292
+
293
+ PLANNED ACTIONS: If an observation says the user planned to do something and the date is now in the past, assume they completed the action unless there's evidence they didn't.
294
+
295
+ USER ASSERTIONS: When observations contain both "User stated: X" and "User asked: X" about the same topic, the assertion is authoritative — the user is the source of truth about their own life.
296
+
297
+ COMPLETION MARKERS: Observations marked with ✅ indicate completed work. Do not re-do or re-investigate tasks marked as complete unless the user explicitly asks to revisit them.`;
298
+
299
+ // ============================================================================
300
+ // Extension
301
+ // ============================================================================
302
+
303
+ export default function observationalMemory(pi: ExtensionAPI) {
304
+ let config: Config = { ...DEFAULTS };
305
+ let state: MemoryState = { observations: "", reflections: "" };
306
+ let compactInFlight = false;
307
+
308
+ // ---- Restore state from last compaction entry ----
309
+
310
+ pi.on("session_start", (_event, ctx) => {
311
+ config = loadConfig(ctx.cwd);
312
+ state = { observations: "", reflections: "" };
313
+ compactInFlight = false;
314
+
315
+ const entries = ctx.sessionManager.getBranch();
316
+ for (let i = entries.length - 1; i >= 0; i--) {
317
+ const entry = entries[i];
318
+ if (entry.type === "compaction" && isMemoryDetails(entry.details)) {
319
+ state.observations = entry.details.observations;
320
+ state.reflections = entry.details.reflections;
321
+ break;
322
+ }
323
+ }
324
+ });
325
+
326
+ // ---- Trigger compaction when raw tail exceeds threshold ----
327
+
328
+ pi.on("agent_end", (_event, ctx) => {
329
+ if (compactInFlight) return;
330
+
331
+ const entries = ctx.sessionManager.getBranch();
332
+ const tokens = estimateRawTailTokens(entries);
333
+ if (tokens < config.observationThreshold) return;
334
+
335
+ compactInFlight = true;
336
+ setTimeout(() => {
337
+ if (!ctx.isIdle()) {
338
+ compactInFlight = false;
339
+ return;
340
+ }
341
+ ctx.compact({
342
+ onComplete: () => {
343
+ compactInFlight = false;
344
+ if (ctx.hasUI) ctx.ui.notify("Observational memory: compaction complete", "info");
345
+ },
346
+ onError: (error) => {
347
+ compactInFlight = false;
348
+ if (ctx.hasUI) ctx.ui.notify(`Observational memory: ${error.message}`, "error");
349
+ },
350
+ });
351
+ }, 0);
352
+ });
353
+
354
+ // ---- Custom compaction: observer + reflector ----
355
+
356
+ pi.on("session_before_compact", async (event, ctx) => {
357
+ const { preparation, signal } = event;
358
+ const { messagesToSummarize, turnPrefixMessages, firstKeptEntryId, tokensBefore } = preparation;
359
+
360
+ let model = ctx.model;
361
+ if (config.compactionModel) {
362
+ const configured = ctx.modelRegistry.find(config.compactionModel.provider, config.compactionModel.id);
363
+ if (configured) {
364
+ model = configured;
365
+ } else if (ctx.hasUI) {
366
+ ctx.ui.notify(
367
+ `Observational memory: configured model ${config.compactionModel.provider}/${config.compactionModel.id} not found, using session model`,
368
+ "warning",
369
+ );
370
+ }
371
+ }
372
+ if (!model) return;
373
+
374
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
375
+ if (!auth.ok || !auth.apiKey) return;
376
+
377
+ const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
378
+ if (allMessages.length === 0) return;
379
+
380
+ const conversationText = serializeConversation(convertToLlm(allMessages));
381
+ const now = new Date();
382
+ const dateStr = now.toISOString().split("T")[0];
383
+ const timeStr = now.toTimeString().slice(0, 5);
384
+
385
+ // ---- Run observer ----
386
+
387
+ ctx.ui.notify("Observational memory: running observer...", "info");
388
+
389
+ try {
390
+ const observerOptions = model.reasoning
391
+ ? { apiKey: auth.apiKey, headers: auth.headers, maxTokens: 4096, signal, reasoning: "high" as const }
392
+ : { apiKey: auth.apiKey, headers: auth.headers, maxTokens: 4096, signal };
393
+
394
+ const observerResponse = await completeSimple(
395
+ model,
396
+ {
397
+ systemPrompt: OBSERVER_SYSTEM,
398
+ messages: [
399
+ {
400
+ role: "user" as const,
401
+ content: [
402
+ {
403
+ type: "text" as const,
404
+ text: `Today is ${dateStr}, current time is ${timeStr}.\n\n<current-reflections>\n${state.reflections || "(none yet)"}\n</current-reflections>\n\n<current-observations>\n${state.observations || "(none yet)"}\n</current-observations>\n\nCompress the following conversation into new observations:\n\n<conversation>\n${conversationText}\n</conversation>`,
405
+ },
406
+ ],
407
+ timestamp: Date.now(),
408
+ },
409
+ ],
410
+ },
411
+ observerOptions,
412
+ );
413
+
414
+ const newObservations = extractText(observerResponse);
415
+ if (!newObservations.trim()) return;
416
+
417
+ state.observations = state.observations
418
+ ? `${state.observations}\n\n${newObservations}`
419
+ : newObservations;
420
+ } catch (error) {
421
+ const msg = error instanceof Error ? error.message : String(error);
422
+ if (ctx.hasUI) ctx.ui.notify(`Observer failed: ${msg}`, "error");
423
+ return;
424
+ }
425
+
426
+ // ---- Run reflector if observations are too large ----
427
+
428
+ if (estimateTokens(state.observations) > config.reflectionThreshold) {
429
+ ctx.ui.notify("Observational memory: running reflector...", "info");
430
+
431
+ try {
432
+ const reflectorOptions = model.reasoning
433
+ ? { apiKey: auth.apiKey, headers: auth.headers, maxTokens: 8192, signal, reasoning: "high" as const }
434
+ : { apiKey: auth.apiKey, headers: auth.headers, maxTokens: 8192, signal };
435
+
436
+ const reflectorResponse = await completeSimple(
437
+ model,
438
+ {
439
+ systemPrompt: REFLECTOR_SYSTEM,
440
+ messages: [
441
+ {
442
+ role: "user" as const,
443
+ content: [
444
+ {
445
+ type: "text" as const,
446
+ text: `Today is ${dateStr}.\n\n<current-reflections>\n${state.reflections || "(none yet)"}\n</current-reflections>\n\n<current-observations>\n${state.observations}\n</current-observations>\n\nGarbage-collect these observations. Promote long-lived facts to reflections, prune what's no longer needed, keep what's still active.`,
447
+ },
448
+ ],
449
+ timestamp: Date.now(),
450
+ },
451
+ ],
452
+ },
453
+ reflectorOptions,
454
+ );
455
+
456
+ const output = extractText(reflectorResponse);
457
+ const reflectionsMatch = output.match(/<reflections>\n?([\s\S]*?)\n?<\/reflections>/);
458
+ const observationsMatch = output.match(/<observations>\n?([\s\S]*?)\n?<\/observations>/);
459
+
460
+ if (reflectionsMatch) state.reflections = reflectionsMatch[1].trim();
461
+ if (observationsMatch) state.observations = observationsMatch[1].trim();
462
+ } catch (error) {
463
+ const msg = error instanceof Error ? error.message : String(error);
464
+ if (ctx.hasUI) ctx.ui.notify(`Reflector failed: ${msg}`, "warning");
465
+ }
466
+ }
467
+
468
+ // ---- Build summary ----
469
+
470
+ let summary = "";
471
+ if (state.reflections) {
472
+ summary += `<reflections>\n${state.reflections}\n</reflections>\n\n`;
473
+ }
474
+ if (state.observations) {
475
+ summary += `<observations>\n${state.observations}\n</observations>`;
476
+ }
477
+
478
+ if (!summary.trim()) return;
479
+
480
+ summary += `\n\n${CONTEXT_USAGE_INSTRUCTIONS}`;
481
+
482
+ const details: MemoryDetails = {
483
+ type: "observational-memory",
484
+ version: 1,
485
+ observations: state.observations,
486
+ reflections: state.reflections,
487
+ };
488
+
489
+ return {
490
+ compaction: {
491
+ summary,
492
+ firstKeptEntryId,
493
+ tokensBefore,
494
+ details,
495
+ },
496
+ };
497
+ });
498
+
499
+ // ---- /om-status command ----
500
+
501
+ pi.registerCommand("om-status", {
502
+ description: "Show observational memory status",
503
+ handler: async (_args, ctx) => {
504
+ const entries = ctx.sessionManager.getBranch();
505
+ const rawTokens = estimateRawTailTokens(entries);
506
+ const obsTokens = estimateTokens(state.observations);
507
+ const refTokens = estimateTokens(state.reflections);
508
+
509
+ const lines = [
510
+ "── Observational Memory ──",
511
+ `Raw messages: ~${rawTokens.toLocaleString()} tokens`,
512
+ `Observations: ~${obsTokens.toLocaleString()} tokens`,
513
+ `Reflections: ~${refTokens.toLocaleString()} tokens`,
514
+ "",
515
+ "── Parameters ──",
516
+ `Observation threshold: ${config.observationThreshold.toLocaleString()}`,
517
+ `Reflection threshold: ${config.reflectionThreshold.toLocaleString()}`,
518
+ ];
519
+
520
+ ctx.ui.notify(lines.join("\n"), "info");
521
+ },
522
+ });
523
+
524
+ // ---- /om-view command ----
525
+
526
+ pi.registerCommand("om-view", {
527
+ description: "Print full observational memory contents (--full to include raw messages)",
528
+ handler: async (args, ctx) => {
529
+ const full = args.includes("--full");
530
+ const sections: string[] = [];
531
+
532
+ sections.push("── Reflections ──");
533
+ sections.push(state.reflections || "(none)");
534
+ sections.push("");
535
+ sections.push("── Observations ──");
536
+ sections.push(state.observations || "(none)");
537
+
538
+ if (full) {
539
+ const entries = ctx.sessionManager.getBranch();
540
+ let startIndex = 0;
541
+ for (let i = entries.length - 1; i >= 0; i--) {
542
+ const entry = entries[i];
543
+ if (entry.type === "compaction") {
544
+ const keptId = entry.firstKeptEntryId;
545
+ let found = false;
546
+ for (let j = 0; j < entries.length; j++) {
547
+ if (entries[j].id === keptId) {
548
+ startIndex = j;
549
+ found = true;
550
+ break;
551
+ }
552
+ }
553
+ if (!found) startIndex = i + 1;
554
+ break;
555
+ }
556
+ }
557
+
558
+ const rawMessages = entries
559
+ .slice(startIndex)
560
+ .filter((e): e is typeof e & { type: "message"; message: unknown } => e.type === "message")
561
+ .map((e) => e.message);
562
+
563
+ sections.push("");
564
+ sections.push("── Raw Messages ──");
565
+ if (rawMessages.length > 0) {
566
+ sections.push(serializeConversation(convertToLlm(rawMessages)));
567
+ } else {
568
+ sections.push("(none)");
569
+ }
570
+ }
571
+
572
+ ctx.ui.notify(sections.join("\n"), "info");
573
+ },
574
+ });
575
+ }