pi-observational-memory 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -94,12 +94,14 @@ That's it. The extension hooks into Pi's compaction lifecycle automatically. No
94
94
 
95
95
  ### Extension settings
96
96
 
97
- Create `~/.pi/agent/observational-memory.json` (or `.pi/observational-memory.json` per project):
97
+ Settings live under the `observational-memory` key in Pi's `settings.json` — globally at `~/.pi/agent/settings.json`, or per-project at `.pi/settings.json`. Project values override global.
98
98
 
99
99
  ```json
100
100
  {
101
- "observationThreshold": 50000,
102
- "reflectionThreshold": 30000
101
+ "observational-memory": {
102
+ "observationThreshold": 50000,
103
+ "reflectionThreshold": 30000
104
+ }
103
105
  }
104
106
  ```
105
107
 
@@ -115,13 +117,15 @@ The observer and reflector don't need the same capabilities as your coding agent
115
117
 
116
118
  ```json
117
119
  {
118
- "compactionModel": { "provider": "openrouter", "id": "google/gemma-4-31b-it" }
120
+ "observational-memory": {
121
+ "compactionModel": { "provider": "openrouter", "id": "google/gemma-4-31b-it" }
122
+ }
119
123
  }
120
124
  ```
121
125
 
122
126
  ### Pi compaction settings
123
127
 
124
- The extension works with Pi's built-in compaction settings in `~/.pi/agent/settings.json`:
128
+ The extension works alongside Pi's built-in compaction settings in the same `settings.json`:
125
129
 
126
130
  ```json
127
131
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-observational-memory",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Observational memory extension for pi — cache-friendly tiered compaction with observations and reflections.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.ts CHANGED
@@ -13,24 +13,26 @@ export const DEFAULTS: Config = {
13
13
  reflectionThreshold: 30_000,
14
14
  };
15
15
 
16
- export function loadConfig(cwd: string): Config {
17
- const globalPath = join(getAgentDir(), "observational-memory.json");
18
- const projectPath = join(cwd, ".pi", "observational-memory.json");
19
-
20
- let globalConfig: Partial<Config> = {};
21
- let projectConfig: Partial<Config> = {};
16
+ const SETTINGS_KEY = "observational-memory";
22
17
 
23
- if (existsSync(globalPath)) {
24
- try {
25
- globalConfig = JSON.parse(readFileSync(globalPath, "utf-8"));
26
- } catch {}
18
+ function readNamespacedConfig(path: string): Partial<Config> {
19
+ if (!existsSync(path)) return {};
20
+ try {
21
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
22
+ const nested = raw[SETTINGS_KEY];
23
+ return nested && typeof nested === "object" ? (nested as Partial<Config>) : {};
24
+ } catch {
25
+ return {};
27
26
  }
27
+ }
28
28
 
29
- if (existsSync(projectPath)) {
30
- try {
31
- projectConfig = JSON.parse(readFileSync(projectPath, "utf-8"));
32
- } catch {}
33
- }
29
+ export function loadConfig(cwd: string): Config {
30
+ const globalPath = join(getAgentDir(), "settings.json");
31
+ const projectPath = join(cwd, ".pi", "settings.json");
34
32
 
35
- return { ...DEFAULTS, ...globalConfig, ...projectConfig };
33
+ return {
34
+ ...DEFAULTS,
35
+ ...readNamespacedConfig(globalPath),
36
+ ...readNamespacedConfig(projectPath),
37
+ };
36
38
  }
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { completeSimple } from "@mariozechner/pi-ai";
1
+ import { completeSimple, type Message, type TextContent, type ToolCall, type ToolResultMessage } from "@mariozechner/pi-ai";
2
2
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
- import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
3
+ import { convertToLlm, SettingsManager } from "@mariozechner/pi-coding-agent";
4
4
  import { DEFAULTS, loadConfig } from "./config.js";
5
5
  import type { Config } from "./config.js";
6
6
  import { CONTEXT_USAGE_INSTRUCTIONS, OBSERVER_SYSTEM, REFLECTOR_SYSTEM } from "./prompts.js";
@@ -8,6 +8,52 @@ import { estimateRawTailTokens, estimateTokens, extractText } from "./tokens.js"
8
8
  import type { MemoryDetails, MemoryState } from "./types.js";
9
9
  import { isMemoryDetails } from "./types.js";
10
10
 
11
+ function localDate(epochMs: number): string {
12
+ const d = new Date(epochMs);
13
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
14
+ }
15
+
16
+ function localTime(epochMs: number): string {
17
+ const d = new Date(epochMs);
18
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
19
+ }
20
+
21
+ function serializeWithTimestamps(messages: Message[]): string {
22
+ return messages
23
+ .map((msg): string | null => {
24
+ const time = localTime(msg.timestamp);
25
+ if (msg.role === "user") {
26
+ const text =
27
+ typeof msg.content === "string"
28
+ ? msg.content
29
+ : msg.content
30
+ .filter((b): b is TextContent => b.type === "text")
31
+ .map((b) => b.text)
32
+ .join("\n");
33
+ return `[User @ ${time}]: ${text}`;
34
+ }
35
+ if (msg.role === "assistant") {
36
+ const parts = msg.content.map((b) => {
37
+ if (b.type === "text") return b.text;
38
+ if (b.type === "thinking") return b.redacted ? "" : `[thinking: ${b.thinking}]`;
39
+ if (b.type === "toolCall") return `[${b.name}(${JSON.stringify(b.arguments)})]`;
40
+ return "";
41
+ });
42
+ const body = parts.filter(Boolean).join("\n");
43
+ if (!body) return null;
44
+ return `[Assistant @ ${time}]: ${body}`;
45
+ }
46
+ // toolResult
47
+ const text = msg.content
48
+ .filter((b): b is TextContent => b.type === "text")
49
+ .map((b) => b.text)
50
+ .join("\n");
51
+ return `[Tool result for ${(msg as ToolResultMessage).toolName} @ ${time}]: ${text}`;
52
+ })
53
+ .filter((line): line is string => line !== null)
54
+ .join("\n\n");
55
+ }
56
+
11
57
  export default function observationalMemory(pi: ExtensionAPI) {
12
58
  let config: Config = { ...DEFAULTS };
13
59
  let state: MemoryState = { observations: "", reflections: "" };
@@ -42,16 +88,22 @@ export default function observationalMemory(pi: ExtensionAPI) {
42
88
  compactInFlight = false;
43
89
  return;
44
90
  }
45
- ctx.compact({
46
- onComplete: () => {
47
- compactInFlight = false;
48
- if (ctx.hasUI) ctx.ui.notify("Observational memory: compaction complete", "info");
49
- },
50
- onError: (error) => {
51
- compactInFlight = false;
52
- if (ctx.hasUI) ctx.ui.notify(`Observational memory: ${error.message}`, "error");
53
- },
54
- });
91
+ try {
92
+ ctx.compact({
93
+ onComplete: () => {
94
+ compactInFlight = false;
95
+ if (ctx.hasUI) ctx.ui.notify("Observational memory: compaction complete", "info");
96
+ },
97
+ onError: (error) => {
98
+ compactInFlight = false;
99
+ if (ctx.hasUI) ctx.ui.notify(`Observational memory: ${error.message}`, "error");
100
+ },
101
+ });
102
+ } catch (error) {
103
+ compactInFlight = false;
104
+ const msg = error instanceof Error ? error.message : String(error);
105
+ if (ctx.hasUI) ctx.ui.notify(`Observational memory: compact threw: ${msg}`, "error");
106
+ }
55
107
  }, 0);
56
108
  });
57
109
 
@@ -79,10 +131,28 @@ export default function observationalMemory(pi: ExtensionAPI) {
79
131
  const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
80
132
  if (allMessages.length === 0) return;
81
133
 
82
- const conversationText = serializeConversation(convertToLlm(allMessages));
134
+ // Bug 1: use local date, not UTC
83
135
  const now = new Date();
84
- const dateStr = now.toISOString().split("T")[0];
85
- const timeStr = now.toTimeString().slice(0, 5);
136
+ const dateStr = localDate(now.getTime());
137
+ const timeStr = localTime(now.getTime());
138
+
139
+ // Bug 2: serialize with per-message timestamps
140
+ const llmMessages = convertToLlm(allMessages);
141
+ const conversationText = serializeWithTimestamps(llmMessages);
142
+
143
+ // Bug 3: inform the Observer of the message date range
144
+ let dateRangeNote = "";
145
+ if (llmMessages.length > 0) {
146
+ const timestamps = llmMessages.map((m) => m.timestamp);
147
+ const firstTs = timestamps.reduce((a, b) => Math.min(a, b));
148
+ const lastTs = timestamps.reduce((a, b) => Math.max(a, b));
149
+ const firstMsgDate = localDate(firstTs);
150
+ const lastMsgDate = localDate(lastTs);
151
+ dateRangeNote =
152
+ firstMsgDate === lastMsgDate
153
+ ? ` Messages in this batch are from ${firstMsgDate}.`
154
+ : ` Messages in this batch span ${firstMsgDate} to ${lastMsgDate}.`;
155
+ }
86
156
 
87
157
  ctx.ui.notify("Observational memory: running observer...", "info");
88
158
 
@@ -101,7 +171,7 @@ export default function observationalMemory(pi: ExtensionAPI) {
101
171
  content: [
102
172
  {
103
173
  type: "text" as const,
104
- 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>`,
174
+ text: `Today is ${dateStr}, current time is ${timeStr}.${dateRangeNote}\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>`,
105
175
  },
106
176
  ],
107
177
  timestamp: Date.now(),
@@ -199,6 +269,7 @@ export default function observationalMemory(pi: ExtensionAPI) {
199
269
  const rawTokens = estimateRawTailTokens(entries);
200
270
  const obsTokens = estimateTokens(state.observations);
201
271
  const refTokens = estimateTokens(state.reflections);
272
+ const keepRecentTokens = SettingsManager.create(ctx.cwd).getCompactionKeepRecentTokens();
202
273
 
203
274
  const lines = [
204
275
  "── Observational Memory ──",
@@ -208,7 +279,8 @@ export default function observationalMemory(pi: ExtensionAPI) {
208
279
  "",
209
280
  "── Parameters ──",
210
281
  `Observation threshold: ${config.observationThreshold.toLocaleString()}`,
211
- `Reflection threshold: ${config.reflectionThreshold.toLocaleString()}`,
282
+ `Reflection threshold: ${config.reflectionThreshold.toLocaleString()} (interpreted as observations token budget)`,
283
+ `Keep recent tokens: ${keepRecentTokens.toLocaleString()} (pi compaction, how many tokens in raw messages)`,
212
284
  ];
213
285
 
214
286
  ctx.ui.notify(lines.join("\n"), "info");
@@ -255,7 +327,7 @@ export default function observationalMemory(pi: ExtensionAPI) {
255
327
  sections.push("");
256
328
  sections.push("── Raw Messages ──");
257
329
  if (rawMessages.length > 0) {
258
- sections.push(serializeConversation(convertToLlm(rawMessages)));
330
+ sections.push(serializeWithTimestamps(convertToLlm(rawMessages)));
259
331
  } else {
260
332
  sections.push("(none)");
261
333
  }
package/src/prompts.ts CHANGED
@@ -1,11 +1,11 @@
1
- export const OBSERVER_SYSTEM = `You are an observation agent for a coding assistant. Compress conversation messages into concise, timestamped observations.
1
+ export const OBSERVER_SYSTEM = `You are an observation agent for a coding assistant. Compress conversation messages into concise, timestamped observations. Messages arrive pre-timestamped as \`[User @ HH:MM]\`, \`[Assistant @ HH:MM]\`, and \`[Tool result for <name> @ HH:MM]\` — use those inline timestamps when assigning times to your observations.
2
2
 
3
3
  Format as a date-grouped log:
4
4
 
5
5
  Date: YYYY-MM-DD
6
6
  - 🔴 HH:MM Observation text
7
- - 🔴 HH:MM Sub-observation
8
- - 🟡 HH:MM Sub-observation
7
+ - Sub-observation (no timestamp if same moment)
8
+ - Sub-observation
9
9
  - 🟢 HH:MM Another observation
10
10
 
11
11
  Priority levels: