pi-observational-memory 1.0.4 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,331 @@
1
+ import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@mariozechner/pi-agent-core";
2
+ import { Type, type Message, type Model } from "@mariozechner/pi-ai";
3
+ import type { Static } from "@sinclair/typebox";
4
+ import { observationsToPromptLines } from "./observer.js";
5
+ import { buildPrunerPassGuidance, CONTEXT_USAGE_INSTRUCTIONS, PRUNER_SYSTEM, REFLECTOR_SYSTEM } from "./prompts.js";
6
+ import { truncateRecordContent } from "./serialize.js";
7
+ import { estimateStringTokens } from "./tokens.js";
8
+ import type { ObservationRecord, Reflection } from "./types.js";
9
+
10
+ const PRUNER_MAX_PASSES = 5;
11
+ const PRUNER_TARGET_RATIO = 0.8;
12
+
13
+ function observationPoolTokens(observations: ObservationRecord[]): number {
14
+ return observations.reduce((sum, o) => sum + estimateStringTokens(o.content), 0);
15
+ }
16
+
17
+ interface LlmArgs {
18
+ model: Model<any>;
19
+ apiKey: string;
20
+ headers?: Record<string, string>;
21
+ signal?: AbortSignal;
22
+ }
23
+
24
+ function joinReflectionsOrEmpty(items: Reflection[]): string {
25
+ return items.length ? items.join("\n") : "(none yet)";
26
+ }
27
+
28
+ function joinObservationsOrEmpty(items: ObservationRecord[]): string {
29
+ return items.length ? observationsToPromptLines(items).join("\n") : "(none yet)";
30
+ }
31
+
32
+ const RecordReflectionsSchema = Type.Object({
33
+ reflections: Type.Array(
34
+ Type.String({
35
+ minLength: 1,
36
+ description: "Single-line plain prose reflection. No markdown, no tags, no timestamp, no bullets.",
37
+ }),
38
+ {
39
+ minItems: 1,
40
+ description: "Batch of new reflections. Each string is one reflection.",
41
+ },
42
+ ),
43
+ });
44
+
45
+ type RecordReflectionsArgs = Static<typeof RecordReflectionsSchema>;
46
+
47
+ export async function runReflector(
48
+ args: LlmArgs,
49
+ reflections: Reflection[],
50
+ observations: ObservationRecord[],
51
+ ): Promise<Reflection[]> {
52
+ const existing = new Set(reflections.map((r) => r.trim()));
53
+ const added = new Set<string>();
54
+
55
+ const recordTool: AgentTool<typeof RecordReflectionsSchema> = {
56
+ name: "record_reflections",
57
+ label: "Record reflections",
58
+ description:
59
+ "Record a batch of new reflections crystallized from the observation pool. " +
60
+ "May be called multiple times. Stop calling when nothing more is stable enough to crystallize, " +
61
+ "then emit a short plain-text confirmation.",
62
+ parameters: RecordReflectionsSchema,
63
+ execute: async (_id, params: RecordReflectionsArgs) => {
64
+ let accepted = 0;
65
+ let duplicates = 0;
66
+ for (const r of params.reflections) {
67
+ const content = truncateRecordContent(r.trim());
68
+ if (!content) continue;
69
+ if (existing.has(content) || added.has(content)) {
70
+ duplicates++;
71
+ continue;
72
+ }
73
+ added.add(content);
74
+ accepted++;
75
+ }
76
+ const parts: string[] = [];
77
+ parts.push(`Recorded ${accepted} new reflection${accepted === 1 ? "" : "s"}.`);
78
+ if (duplicates) parts.push(`${duplicates} duplicate${duplicates === 1 ? "" : "s"} skipped.`);
79
+ parts.push(`Total new this run: ${added.size}.`);
80
+ parts.push("Call record_reflections again if more should be crystallized; otherwise stop and emit a short plain-text confirmation.");
81
+ return {
82
+ content: [{ type: "text", text: parts.join(" ") }],
83
+ details: { accepted, duplicates, total: added.size },
84
+ };
85
+ },
86
+ };
87
+
88
+ const userText = `CURRENT REFLECTIONS:
89
+ ${joinReflectionsOrEmpty(reflections)}
90
+
91
+ CURRENT OBSERVATIONS:
92
+ ${joinObservationsOrEmpty(observations)}
93
+
94
+ Crystallize new long-lived reflections from the observation pool. Call record_reflections with batches of new reflections. You may call the tool multiple times as you reason through the pool. Do not restate reflections already in the current reflections list. When done, stop calling the tool and emit a short plain-text confirmation.`;
95
+
96
+ const prompts: Message[] = [
97
+ {
98
+ role: "user",
99
+ content: [{ type: "text", text: userText }],
100
+ timestamp: Date.now(),
101
+ },
102
+ ];
103
+
104
+ const context: AgentContext = {
105
+ systemPrompt: REFLECTOR_SYSTEM,
106
+ messages: [],
107
+ tools: [recordTool as AgentTool<any>],
108
+ };
109
+
110
+ const reasoning = (args.model as { reasoning?: unknown }).reasoning;
111
+ const config: AgentLoopConfig = {
112
+ model: args.model as any,
113
+ apiKey: args.apiKey,
114
+ headers: args.headers,
115
+ maxTokens: 4096,
116
+ convertToLlm: (msgs) => msgs as Message[],
117
+ toolExecution: "sequential",
118
+ ...(reasoning ? { reasoning: "high" as const } : {}),
119
+ };
120
+
121
+ try {
122
+ const stream = agentLoop(prompts, context, config, args.signal);
123
+ for await (const _event of stream) {
124
+ // Drain events; the tool's execute already collects reflections.
125
+ }
126
+ await stream.result();
127
+ } catch {
128
+ // Salvage any reflections accepted before the error; downstream pruner still runs.
129
+ }
130
+
131
+ return Array.from(added);
132
+ }
133
+
134
+ export interface PrunerResult {
135
+ observations: ObservationRecord[];
136
+ droppedIds: string[];
137
+ fellBack: boolean;
138
+ }
139
+
140
+ const DropObservationsSchema = Type.Object({
141
+ ids: Type.Array(
142
+ Type.String({
143
+ pattern: "^[a-f0-9]{12}$",
144
+ description: "12-character hex observation id from the current-observations list.",
145
+ }),
146
+ {
147
+ minItems: 1,
148
+ description: "Ids of observations to remove from the kept set.",
149
+ },
150
+ ),
151
+ reason: Type.Optional(
152
+ Type.String({ description: "Optional short note explaining why these observations were dropped." }),
153
+ ),
154
+ });
155
+
156
+ type DropObservationsArgs = Static<typeof DropObservationsSchema>;
157
+
158
+ interface PrunerPassContext {
159
+ poolTokens: number;
160
+ targetTokens: number;
161
+ deltaTokens: number;
162
+ pass: number;
163
+ maxPasses: number;
164
+ }
165
+
166
+ interface PrunerPassResult {
167
+ kept: ObservationRecord[];
168
+ droppedIds: string[];
169
+ fellBack: boolean;
170
+ }
171
+
172
+ async function runPrunerPass(
173
+ args: LlmArgs,
174
+ reflections: Reflection[],
175
+ observations: ObservationRecord[],
176
+ passContext: PrunerPassContext,
177
+ ): Promise<PrunerPassResult> {
178
+ const idSet = new Set(observations.map((o) => o.id));
179
+ const dropped = new Set<string>();
180
+
181
+ const dropTool: AgentTool<typeof DropObservationsSchema> = {
182
+ name: "drop_observations",
183
+ label: "Drop observations",
184
+ description:
185
+ "Remove one or more observations from the kept set by id. May be called multiple times. " +
186
+ "Stop calling when no further drops are warranted, then emit a short plain-text confirmation.",
187
+ parameters: DropObservationsSchema,
188
+ execute: async (_id, params: DropObservationsArgs) => {
189
+ const valid: string[] = [];
190
+ const unknown: string[] = [];
191
+ const already: string[] = [];
192
+ for (const id of params.ids) {
193
+ if (!idSet.has(id)) {
194
+ unknown.push(id);
195
+ continue;
196
+ }
197
+ if (dropped.has(id)) {
198
+ already.push(id);
199
+ continue;
200
+ }
201
+ dropped.add(id);
202
+ valid.push(id);
203
+ }
204
+ const remaining = idSet.size - dropped.size;
205
+ const parts: string[] = [];
206
+ parts.push(`Dropped ${valid.length} observation${valid.length === 1 ? "" : "s"}.`);
207
+ if (unknown.length) parts.push(`Unknown ids ignored: ${unknown.join(", ")}.`);
208
+ if (already.length) parts.push(`Already dropped: ${already.join(", ")}.`);
209
+ parts.push(`Remaining kept: ${remaining} of ${idSet.size}.`);
210
+ parts.push("Call drop_observations again if more should be removed; otherwise stop and emit a short plain-text confirmation.");
211
+ return {
212
+ content: [{ type: "text", text: parts.join(" ") }],
213
+ details: { dropped: valid, unknown, already, remaining },
214
+ };
215
+ },
216
+ };
217
+
218
+ const pressureLine =
219
+ passContext.deltaTokens > 0
220
+ ? `Pool ~${passContext.poolTokens.toLocaleString()} tokens, target ~${passContext.targetTokens.toLocaleString()} tokens, still need to cut at least ~${passContext.deltaTokens.toLocaleString()} tokens.`
221
+ : `Pool ~${passContext.poolTokens.toLocaleString()} tokens, target ~${passContext.targetTokens.toLocaleString()} tokens (already under budget) — drop only clear redundancies.`;
222
+
223
+ const passGuidance = buildPrunerPassGuidance(passContext.pass, passContext.maxPasses);
224
+
225
+ const userText = `CURRENT REFLECTIONS:
226
+ ${joinReflectionsOrEmpty(reflections)}
227
+
228
+ CURRENT OBSERVATIONS:
229
+ ${joinObservationsOrEmpty(observations)}
230
+
231
+ ${pressureLine}
232
+
233
+ ${passGuidance}
234
+
235
+ Decide which observations to remove from the kept set. Call drop_observations with the ids you want to drop. You may call the tool multiple times as you reason through the pool. When satisfied, stop calling the tool and emit a short plain-text confirmation to end the run.`;
236
+
237
+ const prompts: Message[] = [
238
+ {
239
+ role: "user",
240
+ content: [{ type: "text", text: userText }],
241
+ timestamp: Date.now(),
242
+ },
243
+ ];
244
+
245
+ const context: AgentContext = {
246
+ systemPrompt: PRUNER_SYSTEM,
247
+ messages: [],
248
+ tools: [dropTool as AgentTool<any>],
249
+ };
250
+
251
+ const reasoning = (args.model as { reasoning?: unknown }).reasoning;
252
+ const config: AgentLoopConfig = {
253
+ model: args.model as any,
254
+ apiKey: args.apiKey,
255
+ headers: args.headers,
256
+ maxTokens: 2048,
257
+ convertToLlm: (msgs) => msgs as Message[],
258
+ toolExecution: "sequential",
259
+ ...(reasoning ? { reasoning: "high" as const } : {}),
260
+ };
261
+
262
+ try {
263
+ const stream = agentLoop(prompts, context, config, args.signal);
264
+ for await (const _event of stream) {
265
+ // Drain events; the tool's execute already records drops.
266
+ }
267
+ await stream.result();
268
+ } catch {
269
+ return { kept: observations, droppedIds: [], fellBack: true };
270
+ }
271
+
272
+ const kept = observations.filter((o) => !dropped.has(o.id));
273
+ return { kept, droppedIds: Array.from(dropped), fellBack: false };
274
+ }
275
+
276
+ export async function runPruner(
277
+ args: LlmArgs,
278
+ reflections: Reflection[],
279
+ observations: ObservationRecord[],
280
+ budgetTokens: number,
281
+ ): Promise<PrunerResult> {
282
+ if (observations.length === 0) {
283
+ return { observations: [], droppedIds: [], fellBack: false };
284
+ }
285
+
286
+ const target = Math.max(1, Math.floor(budgetTokens * PRUNER_TARGET_RATIO));
287
+ let pool = observations;
288
+ const allDropped: string[] = [];
289
+ let fellBack = false;
290
+
291
+ for (let pass = 1; pass <= PRUNER_MAX_PASSES; pass++) {
292
+ const poolTokens = observationPoolTokens(pool);
293
+ if (poolTokens <= target) break;
294
+
295
+ const deltaTokens = poolTokens - target;
296
+ const result = await runPrunerPass(args, reflections, pool, {
297
+ poolTokens,
298
+ targetTokens: target,
299
+ deltaTokens,
300
+ pass,
301
+ maxPasses: PRUNER_MAX_PASSES,
302
+ });
303
+
304
+ if (result.fellBack) {
305
+ fellBack = true;
306
+ break;
307
+ }
308
+ if (result.droppedIds.length === 0) break;
309
+
310
+ pool = result.kept;
311
+ allDropped.push(...result.droppedIds);
312
+ }
313
+
314
+ return { observations: pool, droppedIds: allDropped, fellBack };
315
+ }
316
+
317
+ export function renderSummary(reflections: Reflection[], observations: ObservationRecord[]): string {
318
+ if (reflections.length === 0 && observations.length === 0) return "";
319
+
320
+ const parts: string[] = [CONTEXT_USAGE_INSTRUCTIONS];
321
+
322
+ if (reflections.length > 0) {
323
+ parts.push(`## Reflections\n${reflections.join("\n")}`);
324
+ }
325
+ if (observations.length > 0) {
326
+ const body = observations.map((o) => `${o.timestamp} [${o.relevance}] ${o.content}`).join("\n");
327
+ parts.push(`## Observations\n${body}`);
328
+ }
329
+
330
+ return parts.join("\n\n");
331
+ }
package/src/config.ts CHANGED
@@ -3,14 +3,16 @@ import { join } from "node:path";
3
3
  import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
4
 
5
5
  export interface Config {
6
- observationThreshold: number;
7
- reflectionThreshold: number;
6
+ observationThresholdTokens: number;
7
+ compactionThresholdTokens: number;
8
+ reflectionThresholdTokens: number;
8
9
  compactionModel?: { provider: string; id: string };
9
10
  }
10
11
 
11
12
  export const DEFAULTS: Config = {
12
- observationThreshold: 50_000,
13
- reflectionThreshold: 30_000,
13
+ observationThresholdTokens: 1_000,
14
+ compactionThresholdTokens: 50_000,
15
+ reflectionThresholdTokens: 30_000,
14
16
  };
15
17
 
16
18
  const SETTINGS_KEY = "observational-memory";
@@ -0,0 +1,201 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import {
3
+ collectObservationsByCoverage,
4
+ findLastCompactionIndex,
5
+ gapRawEntries,
6
+ getMemoryState,
7
+ } from "../branch.js";
8
+ import { renderSummary, runPruner, runReflector } from "../compaction.js";
9
+ import { observationsToPromptLines, runObserver } from "../observer.js";
10
+ import type { Runtime } from "../runtime.js";
11
+ import { serializeBranchEntries } from "../serialize.js";
12
+ import { estimateStringTokens } from "../tokens.js";
13
+ import {
14
+ OBSERVATION_CUSTOM_TYPE,
15
+ type MemoryDetails,
16
+ type ObservationEntryData,
17
+ type ObservationRecord,
18
+ type Reflection,
19
+ } from "../types.js";
20
+
21
+ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void {
22
+ pi.on("session_before_compact", async (event, ctx) => {
23
+ if (runtime.compactHookInFlight) {
24
+ if (ctx.hasUI) ctx.ui.notify(
25
+ "Observational memory: another compaction is already in progress; cancelling duplicate",
26
+ "warning",
27
+ );
28
+ return { cancel: true };
29
+ }
30
+ runtime.compactHookInFlight = true;
31
+ try {
32
+ runtime.ensureConfig(ctx.cwd);
33
+ const { preparation, branchEntries, signal } = event;
34
+ const { firstKeptEntryId, tokensBefore } = preparation;
35
+
36
+ const resolved = await runtime.resolveModel(ctx as any);
37
+ if (!resolved.ok) {
38
+ if (ctx.hasUI) ctx.ui.notify(
39
+ `Observational memory: cannot compact — ${resolved.reason}. ` +
40
+ "Fix the model/API key and try /compact manually.",
41
+ "error",
42
+ );
43
+ return { cancel: true };
44
+ }
45
+ runtime.resolveFailureNotified = false;
46
+
47
+ let entries = branchEntries as Parameters<typeof getMemoryState>[0];
48
+
49
+ if (runtime.observerPromise) {
50
+ try { await runtime.observerPromise; } catch { /* already notified via launchObserverTask */ }
51
+ // In-flight observer may have appended a new observation entry during the await;
52
+ // refresh from sessionManager so gap computation and coverage collection see it.
53
+ entries = ctx.sessionManager.getBranch() as typeof entries;
54
+ }
55
+
56
+ const memoryState = getMemoryState(entries);
57
+
58
+ let gapObservationData: ObservationEntryData | null = null;
59
+ const gap = gapRawEntries(entries, firstKeptEntryId);
60
+ if (gap.length > 0) {
61
+ const gapChunk = serializeBranchEntries(gap);
62
+ if (gapChunk.trim()) {
63
+ const gapFromId = gap[0].id;
64
+ const gapUpToId = gap[gap.length - 1].id;
65
+ const priorObservationLines = observationsToPromptLines([
66
+ ...memoryState.committedObs,
67
+ ...memoryState.pendingObs,
68
+ ]);
69
+ const gapTokenEstimate = estimateStringTokens(gapChunk);
70
+ if (ctx.hasUI) ctx.ui.notify(
71
+ `Observational memory: sync catch-up observer running on ~${gapTokenEstimate.toLocaleString()}-token gap`,
72
+ "info",
73
+ );
74
+ runtime.observerInFlight = true;
75
+ const gapCall = runObserver({
76
+ model: resolved.model as any,
77
+ apiKey: resolved.apiKey,
78
+ headers: resolved.headers,
79
+ priorReflections: memoryState.reflections,
80
+ priorObservations: priorObservationLines,
81
+ chunk: gapChunk,
82
+ signal,
83
+ });
84
+ const gapPromise: Promise<void> = gapCall.then(() => undefined, () => undefined);
85
+ runtime.observerPromise = gapPromise;
86
+ try {
87
+ const records = await gapCall;
88
+ if (records && records.length > 0) {
89
+ const observationTokens = records.reduce((sum, r) => sum + estimateStringTokens(r.content), 0);
90
+ gapObservationData = {
91
+ records,
92
+ coversFromId: gapFromId,
93
+ coversUpToId: gapUpToId,
94
+ tokenCount: observationTokens,
95
+ };
96
+ pi.appendEntry(OBSERVATION_CUSTOM_TYPE, gapObservationData);
97
+ if (ctx.hasUI && ctx.ui) ctx.ui.notify(
98
+ `Observational memory: sync catch-up recorded ${records.length} observation${records.length === 1 ? "" : "s"} (~${observationTokens.toLocaleString()} tokens)`,
99
+ "info",
100
+ );
101
+ } else if (ctx.hasUI && ctx.ui) {
102
+ ctx.ui.notify(
103
+ "Observational memory: sync catch-up observer returned empty — proceeding with compaction",
104
+ "warning",
105
+ );
106
+ }
107
+ } catch (error) {
108
+ const msg = error instanceof Error ? error.message : String(error);
109
+ if (ctx.hasUI && ctx.ui) ctx.ui.notify(
110
+ `Observational memory: sync catch-up observer failed: ${msg}. Cancelling compaction — ${gap.length} unobserved raw entries would be pruned without coverage. Try /compact again.`,
111
+ "warning",
112
+ );
113
+ return { cancel: true };
114
+ } finally {
115
+ runtime.observerInFlight = false;
116
+ if (runtime.observerPromise === gapPromise) runtime.observerPromise = null;
117
+ }
118
+ }
119
+ }
120
+
121
+ const priorCompactionIdx = findLastCompactionIndex(entries);
122
+ const priorFirstKeptEntryId = priorCompactionIdx >= 0 ? entries[priorCompactionIdx].firstKeptEntryId : undefined;
123
+ const deltaObservationData = collectObservationsByCoverage(entries, priorFirstKeptEntryId, firstKeptEntryId);
124
+ if (gapObservationData) deltaObservationData.push(gapObservationData);
125
+
126
+ if (deltaObservationData.length === 0) {
127
+ if (ctx.hasUI) ctx.ui.notify("Observational memory: nothing to compact yet", "warning");
128
+ return { cancel: true };
129
+ }
130
+
131
+ const workingReflections: Reflection[] = [...memoryState.reflections];
132
+ const workingObservations: ObservationRecord[] = [
133
+ ...memoryState.committedObs,
134
+ ...deltaObservationData.flatMap((d) => d.records),
135
+ ];
136
+
137
+ const observationTokens = workingObservations.reduce((sum, o) => sum + estimateStringTokens(o.content), 0);
138
+
139
+ let finalReflections = workingReflections;
140
+ let finalObservations = workingObservations;
141
+
142
+ if (observationTokens >= runtime.config.reflectionThresholdTokens) {
143
+ if (ctx.hasUI) ctx.ui.notify("Observational memory: running reflector + pruner...", "info");
144
+ try {
145
+ const newReflections = await runReflector(
146
+ { model: resolved.model as any, apiKey: resolved.apiKey, headers: resolved.headers, signal },
147
+ workingReflections,
148
+ workingObservations,
149
+ );
150
+ finalReflections = [...workingReflections, ...newReflections];
151
+
152
+ const prunerResult = await runPruner(
153
+ { model: resolved.model as any, apiKey: resolved.apiKey, headers: resolved.headers, signal },
154
+ finalReflections,
155
+ workingObservations,
156
+ runtime.config.reflectionThresholdTokens,
157
+ );
158
+ finalObservations = prunerResult.observations;
159
+ if (prunerResult.fellBack && ctx.hasUI) {
160
+ ctx.ui.notify(
161
+ "Observational memory: pruner run failed; kept observation set unchanged",
162
+ "warning",
163
+ );
164
+ }
165
+ } catch (error) {
166
+ const msg = error instanceof Error ? error.message : String(error);
167
+ if (ctx.hasUI) ctx.ui.notify(`Observational memory: reflect/prune failed: ${msg}`, "warning");
168
+ }
169
+ }
170
+
171
+ const summary = renderSummary(finalReflections, finalObservations);
172
+
173
+ if (finalObservations.length === 0) {
174
+ throw new Error("invariant violated: finalObservations empty after delta guard");
175
+ }
176
+
177
+ const details: MemoryDetails = {
178
+ type: "observational-memory",
179
+ version: 3,
180
+ observations: finalObservations,
181
+ reflections: finalReflections,
182
+ };
183
+
184
+ if (ctx.hasUI) ctx.ui.notify(
185
+ `Observational memory: compaction assembled — ${finalObservations.length} observation${finalObservations.length === 1 ? "" : "s"}, ${finalReflections.length} reflection${finalReflections.length === 1 ? "" : "s"}`,
186
+ "info",
187
+ );
188
+
189
+ return {
190
+ compaction: {
191
+ summary,
192
+ firstKeptEntryId,
193
+ tokensBefore,
194
+ details,
195
+ },
196
+ };
197
+ } finally {
198
+ runtime.compactHookInFlight = false;
199
+ }
200
+ });
201
+ }
@@ -0,0 +1,68 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { rawTokensSinceLastCompaction } from "../branch.js";
3
+ import type { Runtime } from "../runtime.js";
4
+
5
+ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
6
+ pi.on("agent_end", (_event, ctx) => {
7
+ runtime.ensureConfig(ctx.cwd);
8
+ if (runtime.compactInFlight) return;
9
+
10
+ const entries = ctx.sessionManager.getBranch() as Parameters<typeof rawTokensSinceLastCompaction>[0];
11
+ const tokens = rawTokensSinceLastCompaction(entries);
12
+ if (tokens < runtime.config.compactionThresholdTokens) return;
13
+
14
+ if (ctx.hasUI) ctx.ui.notify(
15
+ `Observational memory: compaction threshold reached (~${tokens.toLocaleString()} tokens); triggering compaction`,
16
+ "info",
17
+ );
18
+
19
+ runtime.compactInFlight = true;
20
+ setTimeout(async () => {
21
+ if (runtime.observerPromise) {
22
+ try {
23
+ await runtime.observerPromise;
24
+ } catch {
25
+ // errors already surfaced via launchObserverTask
26
+ }
27
+ }
28
+ if (!ctx.isIdle()) {
29
+ runtime.compactInFlight = false;
30
+ if (ctx.hasUI) ctx.ui.notify(
31
+ "Observational memory: compaction deferred — agent became busy after observer wait",
32
+ "info",
33
+ );
34
+ return;
35
+ }
36
+ const currentEntries = ctx.sessionManager.getBranch() as Parameters<typeof rawTokensSinceLastCompaction>[0];
37
+ const currentTokens = rawTokensSinceLastCompaction(currentEntries);
38
+ if (currentTokens < runtime.config.compactionThresholdTokens) {
39
+ runtime.compactInFlight = false;
40
+ if (ctx.hasUI) ctx.ui.notify(
41
+ "Observational memory: compaction skipped — another compaction already ran during observer wait",
42
+ "info",
43
+ );
44
+ return;
45
+ }
46
+ try {
47
+ ctx.compact({
48
+ onComplete: () => {
49
+ runtime.compactInFlight = false;
50
+ if (ctx.hasUI) ctx.ui.notify("Observational memory: compaction complete", "info");
51
+ },
52
+ onError: (error) => {
53
+ runtime.compactInFlight = false;
54
+ if (error.message === "Compaction cancelled") {
55
+ // We already notified the user with the real reason before returning { cancel: true }.
56
+ return;
57
+ }
58
+ if (ctx.hasUI) ctx.ui.notify(`Observational memory: ${error.message}`, "error");
59
+ },
60
+ });
61
+ } catch (error) {
62
+ runtime.compactInFlight = false;
63
+ const msg = error instanceof Error ? error.message : String(error);
64
+ if (ctx.hasUI) ctx.ui.notify(`Observational memory: compact threw: ${msg}`, "error");
65
+ }
66
+ }, 0);
67
+ });
68
+ }