@matthewfl/pi-contemplator 0.0.10 → 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 (44) hide show
  1. package/README.md +12 -12
  2. package/package.json +6 -6
  3. package/src/agents/contemplator/agent.ts +325 -91
  4. package/src/agents/contemplator/prompts.ts +6 -6
  5. package/src/agents/observer/agent.ts +14 -6
  6. package/src/agents/observer/prompts.ts +16 -7
  7. package/src/agents/reviewer/agent.ts +24 -4
  8. package/src/agents/reviewer/prompts.ts +1 -1
  9. package/src/agents/reviewer/tools.ts +24 -9
  10. package/src/agents/stream-errors.ts +1 -1
  11. package/src/agents/summarizer/agent.ts +597 -0
  12. package/src/agents/summarizer/prompts.ts +46 -0
  13. package/src/agents/summarizer/sampling.ts +80 -0
  14. package/src/commands/contemplator-view.ts +22 -1
  15. package/src/commands/settings.ts +73 -69
  16. package/src/commands/status.ts +60 -36
  17. package/src/commands/summarizer-view.ts +58 -0
  18. package/src/commands/view.ts +22 -10
  19. package/src/config.ts +25 -32
  20. package/src/hooks/compaction-hook.ts +32 -17
  21. package/src/hooks/compaction-resume.ts +4 -4
  22. package/src/hooks/compaction-trigger.ts +33 -11
  23. package/src/hooks/consolidation-trigger.ts +213 -196
  24. package/src/memory-citations.ts +37 -0
  25. package/src/required-tool-choice.ts +28 -0
  26. package/src/runtime.ts +115 -32
  27. package/src/session-ledger/fold.ts +82 -53
  28. package/src/session-ledger/index.ts +1 -0
  29. package/src/session-ledger/pools.ts +77 -0
  30. package/src/session-ledger/progress.ts +7 -18
  31. package/src/session-ledger/projection.ts +45 -177
  32. package/src/session-ledger/recall.ts +129 -127
  33. package/src/session-ledger/render-summary.ts +20 -19
  34. package/src/session-ledger/search.ts +99 -115
  35. package/src/session-ledger/types.ts +102 -75
  36. package/src/tools/compact-context.ts +1 -1
  37. package/src/tools/recall-observation.ts +99 -459
  38. package/src/tools/search-memories.ts +31 -72
  39. package/src/agents/dropper/agent.ts +0 -291
  40. package/src/agents/dropper/coverage.ts +0 -128
  41. package/src/agents/dropper/pool.ts +0 -67
  42. package/src/agents/dropper/prompts.ts +0 -48
  43. package/src/agents/reflector/agent.ts +0 -213
  44. package/src/agents/reflector/prompts.ts +0 -81
@@ -3,11 +3,13 @@ import type { Runtime } from "../runtime.js";
3
3
  import { copyTextToClipboard } from "../clipboard.js";
4
4
  import { renderContemplator, stripAnsi } from "./contemplator-view.js";
5
5
  import { renderReviewer } from "./reviewer-view.js";
6
+ import { renderSummarizer } from "./summarizer-view.js";
6
7
  import { executeRecall, formatRecallResultForTui } from "../tools/recall-observation.js";
7
8
  import {
9
+ chronologicalMemories,
8
10
  fullProjection,
9
11
  observationToSummaryLine,
10
- reflectionToSummaryLine,
12
+ summaryToSummaryLine,
11
13
  visibleProjection,
12
14
  type Entry,
13
15
  type Projection,
@@ -44,12 +46,12 @@ function renderContentOnlyProjection(
44
46
  projection: Projection,
45
47
  emptyScope: "visible" | "recorded",
46
48
  ): string {
49
+ const memories = chronologicalMemories(projection.observations, projection.summaries);
47
50
  const lines = [
48
- "── Reflections ──",
49
- renderList(projection.reflections, reflectionToSummaryLine, `No ${emptyScope} reflections.`),
50
- "",
51
- "── Observations ──",
52
- renderList(projection.observations, observationToSummaryLine, `No ${emptyScope} observations.`),
51
+ "── Memories (chronological) ──",
52
+ memories.length > 0
53
+ ? memories.map((item) => item.kind === "observation" ? observationToSummaryLine(item.memory) : summaryToSummaryLine(item.memory)).join("\n")
54
+ : `No ${emptyScope} memories.`,
53
55
  ];
54
56
  if (projection.reviews?.length) lines.push("", "── Advisory reviews ──", ...projection.reviews.map(reviewSummaryLine));
55
57
  return lines.join("\n");
@@ -57,7 +59,7 @@ function renderContentOnlyProjection(
57
59
 
58
60
  function hasMemory(projection: Projection): boolean {
59
61
  return (
60
- projection.reflections.length > 0 || projection.observations.length > 0 || (projection.reviews?.length ?? 0) > 0
62
+ projection.summaries.length > 0 || projection.observations.length > 0 || (projection.reviews?.length ?? 0) > 0
61
63
  );
62
64
  }
63
65
 
@@ -74,7 +76,7 @@ export function registerViewCommand(
74
76
 
75
77
  pi.registerCommand("om:view", {
76
78
  description:
77
- "Print and copy observational memory content (visible, full, memory, contemplator, reviewer, or reviews)",
79
+ "Print and copy pi-contemplator memory content (visible, full, memory, contemplator, summarizer, reviewer, or reviews)",
78
80
  handler: async (args, ctx) => {
79
81
  runtime.ensureConfig(ctx.cwd);
80
82
  const entries = ctx.sessionManager.getBranch() as Entry[];
@@ -105,7 +107,7 @@ export function registerViewCommand(
105
107
  }
106
108
 
107
109
  if (mode === "contemplator") {
108
- const output = renderContemplator(entries);
110
+ const output = renderContemplator(entries, runtime.contemplatorState);
109
111
  const copied = await copyToClipboard(stripAnsi(output)).catch(() => false);
110
112
  ctx.ui.notify(
111
113
  `${output}\n\n${copied ? "Copied /om:view contemplator output to clipboard." : "Warning: failed to copy /om:view contemplator output to clipboard."}`,
@@ -114,6 +116,16 @@ export function registerViewCommand(
114
116
  return;
115
117
  }
116
118
 
119
+ if (mode === "summarizer") {
120
+ const output = renderSummarizer(runtime.lastSummarizerRun);
121
+ const copied = await copyToClipboard(stripAnsi(output)).catch(() => false);
122
+ ctx.ui.notify(
123
+ `${output}\n\n${copied ? "Copied /om:view summarizer output to clipboard." : "Warning: failed to copy /om:view summarizer output to clipboard."}`,
124
+ "info",
125
+ );
126
+ return;
127
+ }
128
+
117
129
  if (mode === "reviewer") {
118
130
  const output = renderReviewer(entries);
119
131
  const copied = await copyToClipboard(stripAnsi(output)).catch(() => false);
@@ -142,7 +154,7 @@ export function registerViewCommand(
142
154
  }
143
155
 
144
156
  if (mode && mode !== "visible") {
145
- ctx.ui.notify("Usage: /om:view [visible|full|memory <id>|contemplator|reviewer|reviews]", "info");
157
+ ctx.ui.notify("Usage: /om:view [visible|full|memory <id>|contemplator|summarizer|reviewer|reviews]", "info");
146
158
  return;
147
159
  }
148
160
 
package/src/config.ts CHANGED
@@ -32,7 +32,6 @@ export type CompactAfterTokensMode = "calibrated" | "ratio";
32
32
 
33
33
  export interface Config {
34
34
  observeAfterTokens: number;
35
- reflectAfterTokens: number;
36
35
  /**
37
36
  * Maximum estimated source tokens serialized into a single observer chunk.
38
37
  * Unset (default) derives the cap from the resolved memory model's context
@@ -42,8 +41,10 @@ export interface Config {
42
41
  compactAfterTokens: number;
43
42
  compactAfterTokensMode: CompactAfterTokensMode;
44
43
  compactAfterTokensRatio: number;
45
- observationsPoolMaxTokens: number;
46
- observationsPoolTargetTokens: number;
44
+ /** Token budget for the protected newest-memory suffix; newest record always fits whole. */
45
+ newMemoryPoolMaxTokens: number;
46
+ /** Advisory token target for older summarizer-eligible memory. */
47
+ oldMemoryPoolTargetTokens: number;
47
48
  agentMaxTurns: number;
48
49
  model?: ConfiguredModel;
49
50
  showWorkerNotifications: boolean;
@@ -59,19 +60,25 @@ export interface Config {
59
60
  /** Optional model override used only by short-lived structural reviewers. */
60
61
  reviewerModel?: ConfiguredModel;
61
62
  contemplatorMinNewObservations: number;
62
- contemplatorMinNewReflections: number;
63
+ contemplatorMinNewSummaries: number;
64
+ /** Minimum completed primary-model responses between contemplator runs. */
63
65
  contemplatorMinTurns: number;
66
+ /** Stateless loss-aware summarizer for the old memory pool. */
67
+ summarizerEnabled: boolean;
68
+ /** Additional old-pool tokens required before retrying an above-target pool. */
69
+ summarizerRetriggerTokens: number;
70
+ /** Rendered old-memory tokens available before pressure-valve sampling. */
71
+ summarizerSamplingThresholdTokens: number;
64
72
  debugLog: boolean;
65
73
  }
66
74
 
67
75
  export const DEFAULTS: Config = {
68
76
  observeAfterTokens: 10_000,
69
- reflectAfterTokens: 20_000,
70
77
  compactAfterTokens: 81_000,
71
78
  compactAfterTokensMode: "calibrated",
72
79
  compactAfterTokensRatio: 0.68,
73
- observationsPoolMaxTokens: 20_000,
74
- observationsPoolTargetTokens: 10_000,
80
+ newMemoryPoolMaxTokens: 40_000,
81
+ oldMemoryPoolTargetTokens: 40_000,
75
82
  agentMaxTurns: 16,
76
83
  showWorkerNotifications: true,
77
84
  passive: false,
@@ -80,8 +87,11 @@ export const DEFAULTS: Config = {
80
87
  showContemplatorMessages: true,
81
88
  reviewerEnabled: true,
82
89
  contemplatorMinNewObservations: 8,
83
- contemplatorMinNewReflections: 1,
90
+ contemplatorMinNewSummaries: 1,
84
91
  contemplatorMinTurns: 10,
92
+ summarizerEnabled: true,
93
+ summarizerRetriggerTokens: 2_000,
94
+ summarizerSamplingThresholdTokens: 60_000,
85
95
  debugLog: false,
86
96
  };
87
97
 
@@ -155,15 +165,6 @@ function positiveIntegerOrUndefined(value: unknown): number | undefined {
155
165
  return Number.isInteger(value) && typeof value === "number" && value > 0 ? value : undefined;
156
166
  }
157
167
 
158
- function validTargetOrUndefined(value: unknown, maxTokens: number): number | undefined {
159
- const target = positiveIntegerOrUndefined(value);
160
- return target !== undefined && target < maxTokens ? target : undefined;
161
- }
162
-
163
- function derivedObservationPoolTarget(maxTokens: number): number {
164
- return Math.floor(maxTokens / 2);
165
- }
166
-
167
168
  function isThinkingLevel(value: unknown): value is ModelThinkingLevel {
168
169
  return typeof value === "string" && (THINKING_LEVEL_VALUES as readonly string[]).includes(value);
169
170
  }
@@ -203,15 +204,16 @@ function normalizeSettingsConfig(value: Record<string, unknown>): Partial<Config
203
204
  const normalized: Partial<Config> = {};
204
205
  const numberKeys = [
205
206
  "observeAfterTokens",
206
- "reflectAfterTokens",
207
207
  "observerChunkMaxTokens",
208
208
  "compactAfterTokens",
209
- "observationsPoolMaxTokens",
210
- "observationsPoolTargetTokens",
209
+ "newMemoryPoolMaxTokens",
210
+ "oldMemoryPoolTargetTokens",
211
211
  "agentMaxTurns",
212
212
  "contemplatorMinNewObservations",
213
- "contemplatorMinNewReflections",
213
+ "contemplatorMinNewSummaries",
214
214
  "contemplatorMinTurns",
215
+ "summarizerRetriggerTokens",
216
+ "summarizerSamplingThresholdTokens",
215
217
  ] as const;
216
218
  for (const key of numberKeys) {
217
219
  const normalizedValue = positiveIntegerOrUndefined(value[key]);
@@ -228,6 +230,7 @@ function normalizeSettingsConfig(value: Record<string, unknown>): Partial<Config
228
230
  if (typeof value.contemplatorEnabled === "boolean") normalized.contemplatorEnabled = value.contemplatorEnabled;
229
231
  if (typeof value.showContemplatorMessages === "boolean") normalized.showContemplatorMessages = value.showContemplatorMessages;
230
232
  if (typeof value.reviewerEnabled === "boolean") normalized.reviewerEnabled = value.reviewerEnabled;
233
+ if (typeof value.summarizerEnabled === "boolean") normalized.summarizerEnabled = value.summarizerEnabled;
231
234
  if (typeof value.debugLog === "boolean") normalized.debugLog = value.debugLog;
232
235
  const model = normalizeModel(value.model);
233
236
  if (model) normalized.model = model;
@@ -269,20 +272,10 @@ export function loadConfig(cwd: string, env: NodeJS.ProcessEnv = process.env): C
269
272
  const globalConfig = readNamespacedConfig(globalPath);
270
273
  const projectConfig = readNamespacedConfig(projectPath);
271
274
  const envConfig = readEnvConfig(env);
272
- const merged = {
275
+ return {
273
276
  ...DEFAULTS,
274
- observationsPoolTargetTokens: undefined,
275
277
  ...globalConfig,
276
278
  ...projectConfig,
277
279
  ...envConfig,
278
280
  };
279
- const target = validTargetOrUndefined(
280
- merged.observationsPoolTargetTokens,
281
- merged.observationsPoolMaxTokens,
282
- ) ?? derivedObservationPoolTarget(merged.observationsPoolMaxTokens);
283
-
284
- return {
285
- ...merged,
286
- observationsPoolTargetTokens: target,
287
- };
288
281
  }
@@ -1,26 +1,19 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
+ import { debugLog } from "../debug-log.js";
3
4
  import { computeSessionSettings, type Runtime } from "../runtime.js";
4
5
  import { launchCompactionObserver, type ConsolidationCtx } from "./consolidation-trigger.js";
5
6
  import { buildCompactionProjection, renderSummary, type Entry } from "../session-ledger/index.js";
6
7
  import { watchForNativeCompactionResume } from "./compaction-resume.js";
7
8
 
8
- const DEFAULT_OBSERVATIONS_POOL_MAX_TOKENS = 20_000;
9
9
  const COMPACTION_STATUS_KEY = "observational-memory-compaction";
10
10
 
11
- function observationsPoolMaxTokens(runtime: Runtime): number {
12
- const value = (runtime.config as { observationsPoolMaxTokens?: unknown }).observationsPoolMaxTokens;
13
- return typeof value === "number" && Number.isFinite(value) && value > 0
14
- ? value
15
- : DEFAULT_OBSERVATIONS_POOL_MAX_TOKENS;
16
- }
17
-
18
11
  export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void {
19
12
  pi.on("session_before_compact", async (event: any, ctx: any) => {
20
13
  if (runtime.compactHookInFlight) {
21
14
  if (ctx.hasUI) {
22
15
  ctx.ui.notify(
23
- "Observational memory: another compaction is already in progress; cancelling duplicate",
16
+ "pi-contemplator: another compaction is already in progress; cancelling duplicate",
24
17
  "warning",
25
18
  );
26
19
  }
@@ -37,7 +30,7 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
37
30
  ctx.ui.setStatus?.(COMPACTION_STATUS_KEY, `OM compaction: running (${reason}${pending})`);
38
31
  if (!initiatedByOm) {
39
32
  const continuation = event.willRetry ? "; the interrupted agent run will resume automatically" : "";
40
- ctx.ui.notify(`Observational memory: compaction started (${reason})${continuation}`, "info");
33
+ ctx.ui.notify(`pi-contemplator: compaction started (${reason})${continuation}`, "info");
41
34
  }
42
35
  }
43
36
  event.signal?.addEventListener?.("abort", () => {
@@ -55,12 +48,8 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
55
48
  launchCompactionObserver(pi, runtime, ctx as ConsolidationCtx, branch);
56
49
  }
57
50
  const { firstKeptEntryId, tokensBefore } = preparation;
58
- const projection = buildCompactionProjection(
59
- branch,
60
- firstKeptEntryId,
61
- { observationsPoolMaxTokens: observationsPoolMaxTokens(runtime) },
62
- );
63
- const summary = renderSummary(projection.reflections, projection.observations);
51
+ const projection = buildCompactionProjection(branch, firstKeptEntryId);
52
+ const summary = renderSummary(projection.summaries, projection.observations);
64
53
  // Compaction removes older custom entries from the active branch. Keep
65
54
  // session-scoped overrides in the compaction details so they can be
66
55
  // restored after a reload from the surviving branch. Bake the merged
@@ -96,6 +85,32 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
96
85
  let continuation = "";
97
86
  if (event.willRetry) continuation = "; resuming the interrupted agent run";
98
87
  else if (omWillResume) continuation = "; resuming the agent run";
99
- ctx.ui.notify(`Observational memory: compaction complete (${reason})${continuation}`, "info");
88
+ ctx.ui.notify(`pi-contemplator: compaction complete (${reason})${continuation}`, "info");
89
+ });
90
+
91
+ pi.on("session_compact_failed", (event, ctx) => {
92
+ const initiatedByOm = runtime.compactInFlight && event.reason === "manual";
93
+ const reason = initiatedByOm ? (runtime.compactOrigin ?? "proactive") : event.reason;
94
+ debugLog("compaction.failed", {
95
+ reason,
96
+ piReason: event.reason,
97
+ errorMessage: event.errorMessage,
98
+ aborted: event.aborted,
99
+ willRetry: event.willRetry,
100
+ fromExtension: event.fromExtension,
101
+ initiatedByOm,
102
+ });
103
+
104
+ // OM-initiated ctx.compact() calls already have an onError callback that
105
+ // clears UI state and applies the origin-specific continuation policy. Do
106
+ // not duplicate that work here; Pi emits this event before invoking it.
107
+ if (initiatedByOm) return;
108
+ if (ctx.hasUI) ctx.ui.setStatus?.(COMPACTION_STATUS_KEY, undefined);
109
+ if (!event.aborted && ctx.hasUI) {
110
+ ctx.ui.notify(
111
+ `pi-contemplator: compaction failed (${reason}): ${event.errorMessage ?? "unknown error"}`,
112
+ "error",
113
+ );
114
+ }
100
115
  });
101
116
  }
@@ -53,7 +53,7 @@ function sendResumeMessage(pi: ExtensionAPI, ctx: ResumeCtx, afterFailure: boole
53
53
  });
54
54
  } catch (error) {
55
55
  const message = error instanceof Error ? error.message : String(error);
56
- ctx.ui?.notify?.(`Observational memory: failed to request continuation: ${message}`, "error");
56
+ ctx.ui?.notify?.(`pi-contemplator: failed to request continuation: ${message}`, "error");
57
57
  }
58
58
  }
59
59
 
@@ -72,7 +72,7 @@ function scheduleResumeRetries(
72
72
  if (!isCurrentWatch(runtime, generation)) return;
73
73
  clearResumeWatch(runtime);
74
74
  ctx.ui?.notify?.(
75
- "Observational memory: the agent did not acknowledge continuation after compaction",
75
+ "pi-contemplator: the agent did not acknowledge continuation after compaction",
76
76
  "error",
77
77
  );
78
78
  }, RESUME_RETRY_DELAYS_MS.at(-1));
@@ -82,7 +82,7 @@ function scheduleResumeRetries(
82
82
  runtime.compactionResumeTimer = setTimeout(() => {
83
83
  if (!isCurrentWatch(runtime, generation)) return;
84
84
  ctx.ui?.notify?.(
85
- `Observational memory: continuation did not start; retrying (${retryIndex + 1}/${RESUME_RETRY_DELAYS_MS.length})`,
85
+ `pi-contemplator: continuation did not start; retrying (${retryIndex + 1}/${RESUME_RETRY_DELAYS_MS.length})`,
86
86
  "warning",
87
87
  );
88
88
  sendResumeMessage(pi, ctx, afterFailure, shortContinuationPrompt);
@@ -118,7 +118,7 @@ export function watchForNativeCompactionResume(
118
118
  runtime.compactionResumeTimer = setTimeout(() => {
119
119
  if (!isCurrentWatch(runtime, generation)) return;
120
120
  ctx.ui?.notify?.(
121
- "Observational memory: native compaction did not resume the agent; sending fallback continuation",
121
+ "pi-contemplator: native compaction did not resume the agent; sending fallback continuation",
122
122
  "warning",
123
123
  );
124
124
  sendResumeMessage(pi, ctx, false);
@@ -18,8 +18,20 @@ type TriggerOptions = {
18
18
  shortContinuationPrompt?: string;
19
19
  };
20
20
 
21
+ /** A stop with thinking but no text or tool call did not produce a usable turn. */
22
+ function isEmptyNormalStop(message: any): boolean {
23
+ if (!message || message.role !== "assistant" || message.stopReason !== "stop") return false;
24
+ if (typeof message.content === "string") return message.content.trim().length === 0;
25
+ if (!Array.isArray(message.content)) return true;
26
+ return !message.content.some((part: any) =>
27
+ part?.type === "toolCall"
28
+ || (part?.type === "text" && typeof part.text === "string" && part.text.trim().length > 0),
29
+ );
30
+ }
31
+
21
32
  export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
22
33
  registerCompactionResumeAcknowledgement(pi, runtime);
34
+ let resumeEmptyStopAfterProactiveCompaction = false;
23
35
 
24
36
  const triggerCompaction = (ctx: any, options: TriggerOptions): void => {
25
37
  const { origin, resume, threshold, shortContinuationPrompt } = options;
@@ -35,7 +47,7 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
35
47
  runtime.compactOrigin = undefined;
36
48
  if (origin === "agent-requested") runtime.compactRequested = true;
37
49
  if (hasUI) ui?.notify(
38
- "Observational memory: compaction deferred — agent became busy before compaction",
50
+ "pi-contemplator: compaction deferred — agent became busy before compaction",
39
51
  "info",
40
52
  );
41
53
  return;
@@ -46,7 +58,7 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
46
58
  runtime.compactInFlight = false;
47
59
  runtime.compactOrigin = undefined;
48
60
  if (hasUI) ui?.notify(
49
- "Observational memory: compaction skipped — another compaction already ran before deferred compaction",
61
+ "pi-contemplator: compaction skipped — another compaction already ran before deferred compaction",
50
62
  "info",
51
63
  );
52
64
  return;
@@ -57,7 +69,7 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
57
69
  const reason = origin === "agent-requested" ? "agent-requested, " : origin === "length-stop" ? "length-stop, " : "";
58
70
  const continuation = resume ? "; the interrupted agent run will resume automatically" : "";
59
71
  ui?.notify(
60
- `Observational memory: compaction started (${reason}~${currentTokens.toLocaleString()} tokens)${continuation}`,
72
+ `pi-contemplator: compaction started (${reason}~${currentTokens.toLocaleString()} tokens)${continuation}`,
61
73
  "info",
62
74
  );
63
75
  }
@@ -74,7 +86,7 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
74
86
  runtime.compactOrigin = undefined;
75
87
  if (hasUI) ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
76
88
  if (error.message !== "Compaction cancelled" && hasUI) {
77
- ui?.notify(`Observational memory: ${error.message}`, "error");
89
+ ui?.notify(`pi-contemplator: ${error.message}`, "error");
78
90
  }
79
91
  if (resume) resumeAfterCompaction(pi, runtime, { hasUI, ui }, true, shortContinuationPrompt);
80
92
  },
@@ -86,7 +98,7 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
86
98
  const msg = error instanceof Error ? error.message : String(error);
87
99
  if (hasUI) {
88
100
  ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
89
- ui?.notify(`Observational memory: compact threw: ${msg}`, "error");
101
+ ui?.notify(`pi-contemplator: compact threw: ${msg}`, "error");
90
102
  }
91
103
  if (resume) resumeAfterCompaction(pi, runtime, { hasUI, ui }, true, shortContinuationPrompt);
92
104
  }
@@ -97,6 +109,15 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
97
109
  runtime.ensureConfig(ctx.cwd);
98
110
  if (runtime.compactInFlight) return;
99
111
 
112
+ const lastAssistant = [...event.messages].reverse().find(
113
+ (m): m is Extract<typeof m, { role: "assistant" }> => m.role === "assistant",
114
+ );
115
+ // Some providers occasionally return stop after spending output tokens but
116
+ // emit no text or tool call. Pi regards that as settled, yet it plainly is
117
+ // not a completed autonomous turn. Remember this only until agent_settled so
118
+ // threshold compaction can continue it; never resume an ordinary text stop.
119
+ resumeEmptyStopAfterProactiveCompaction = isEmptyNormalStop(lastAssistant);
120
+
100
121
  const agentRequested = runtime.compactRequested;
101
122
  if (agentRequested) {
102
123
  const shortContinuationPrompt = runtime.compactContinuationPrompt;
@@ -108,9 +129,6 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
108
129
 
109
130
  // Pi owns error, abort, and overflow retry policy. OM's session hook still
110
131
  // supplies the compaction contents when Pi performs a native retry.
111
- const lastAssistant = [...event.messages].reverse().find(
112
- (m): m is Extract<typeof m, { role: "assistant" }> => m.role === "assistant",
113
- );
114
132
  const contextWindow = typeof ctx.model?.contextWindow === "number" ? ctx.model.contextWindow : 0;
115
133
  if (
116
134
  !lastAssistant
@@ -129,15 +147,19 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
129
147
  });
130
148
 
131
149
  // Proactive threshold compaction is maintenance after Pi has fully settled.
132
- // It must not manufacture another agent turn: the previous run completed
133
- // normally and there is no interrupted work to resume.
150
+ // It must not manufacture another agent turn after an ordinary completed
151
+ // response. The narrow exception is a provider's empty normal stop: there was
152
+ // no usable response, so compaction must preserve the autonomous run rather
153
+ // than making that provider failure look like successful completion.
134
154
  pi.on("agent_settled", (_event: any, ctx: any) => {
155
+ const resume = resumeEmptyStopAfterProactiveCompaction;
156
+ resumeEmptyStopAfterProactiveCompaction = false;
135
157
  runtime.ensureConfig(ctx.cwd);
136
158
  if (runtime.config.passive === true || runtime.compactInFlight || runtime.compactRequested) return;
137
159
  const entries = ctx.sessionManager.getBranch() as Entry[];
138
160
  const contextWindow = typeof ctx.model?.contextWindow === "number" ? ctx.model.contextWindow : undefined;
139
161
  const threshold = resolveCompactAfterTokens(runtime.config, contextWindow);
140
162
  if (rawTokensSinceLastCompaction(entries) < threshold) return;
141
- triggerCompaction(ctx, { origin: "proactive", resume: false, threshold });
163
+ triggerCompaction(ctx, { origin: "proactive", resume, threshold });
142
164
  });
143
165
  }