@matthewfl/pi-contemplator 0.0.9 → 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 +17 -11
  2. package/package.json +8 -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 +36 -19
  21. package/src/hooks/compaction-resume.ts +4 -4
  22. package/src/hooks/compaction-trigger.ts +96 -56
  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 +116 -33
  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
  }
@@ -29,14 +22,15 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
29
22
 
30
23
  const initiatedByOm = runtime.compactInFlight && event.reason === "manual";
31
24
  const reason = initiatedByOm ? (runtime.compactOrigin ?? "proactive") : event.reason;
25
+ const omWillResume = initiatedByOm && (runtime.compactOrigin === "agent-requested" || runtime.compactOrigin === "length-stop");
32
26
  if (ctx.hasUI) {
33
27
  let pending = "";
34
28
  if (event.willRetry) pending = ", retry pending";
35
- else if (initiatedByOm) pending = ", resume pending";
29
+ else if (omWillResume) pending = ", resume pending";
36
30
  ctx.ui.setStatus?.(COMPACTION_STATUS_KEY, `OM compaction: running (${reason}${pending})`);
37
31
  if (!initiatedByOm) {
38
32
  const continuation = event.willRetry ? "; the interrupted agent run will resume automatically" : "";
39
- ctx.ui.notify(`Observational memory: compaction started (${reason})${continuation}`, "info");
33
+ ctx.ui.notify(`pi-contemplator: compaction started (${reason})${continuation}`, "info");
40
34
  }
41
35
  }
42
36
  event.signal?.addEventListener?.("abort", () => {
@@ -54,12 +48,8 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
54
48
  launchCompactionObserver(pi, runtime, ctx as ConsolidationCtx, branch);
55
49
  }
56
50
  const { firstKeptEntryId, tokensBefore } = preparation;
57
- const projection = buildCompactionProjection(
58
- branch,
59
- firstKeptEntryId,
60
- { observationsPoolMaxTokens: observationsPoolMaxTokens(runtime) },
61
- );
62
- const summary = renderSummary(projection.reflections, projection.observations);
51
+ const projection = buildCompactionProjection(branch, firstKeptEntryId);
52
+ const summary = renderSummary(projection.summaries, projection.observations);
63
53
  // Compaction removes older custom entries from the active branch. Keep
64
54
  // session-scoped overrides in the compaction details so they can be
65
55
  // restored after a reload from the surviving branch. Bake the merged
@@ -88,12 +78,39 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
88
78
  pi.on("session_compact", (event: any, ctx: any) => {
89
79
  const initiatedByOm = runtime.compactInFlight && event.reason === "manual";
90
80
  const reason = initiatedByOm ? (runtime.compactOrigin ?? "proactive") : event.reason;
81
+ const omWillResume = initiatedByOm && (runtime.compactOrigin === "agent-requested" || runtime.compactOrigin === "length-stop");
91
82
  if (event.willRetry) watchForNativeCompactionResume(pi, runtime, ctx);
92
83
  if (!ctx.hasUI) return;
93
84
  ctx.ui.setStatus?.(COMPACTION_STATUS_KEY, undefined);
94
85
  let continuation = "";
95
86
  if (event.willRetry) continuation = "; resuming the interrupted agent run";
96
- else if (initiatedByOm) continuation = "; resuming the agent run";
97
- ctx.ui.notify(`Observational memory: compaction complete (${reason})${continuation}`, "info");
87
+ else if (omWillResume) continuation = "; resuming the agent run";
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
+ }
98
115
  });
99
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);
@@ -9,52 +9,34 @@ import {
9
9
  } from "./compaction-resume.js";
10
10
 
11
11
  const COMPACTION_STATUS_KEY = "observational-memory-compaction";
12
+ type CompactionOrigin = "agent-requested" | "length-stop" | "proactive";
12
13
 
13
- export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
14
- registerCompactionResumeAcknowledgement(pi, runtime);
15
- pi.on("agent_end", (event: any, ctx: any) => {
16
- runtime.ensureConfig(ctx.cwd);
17
- if (runtime.compactInFlight) return;
18
-
19
- const agentRequested = runtime.compactRequested;
20
- const shortContinuationPrompt = agentRequested ? runtime.compactContinuationPrompt : undefined;
21
- if (agentRequested) runtime.compactRequested = false;
22
- else if (runtime.config.passive === true) return;
14
+ type TriggerOptions = {
15
+ origin: CompactionOrigin;
16
+ resume: boolean;
17
+ threshold?: number;
18
+ shortContinuationPrompt?: string;
19
+ };
23
20
 
24
- const contextWindow = typeof ctx.model?.contextWindow === "number" ? ctx.model.contextWindow : 0;
25
- let threshold: number | undefined;
26
- if (!agentRequested) {
27
- // agent_end fires before Pi decides whether to retry or compact-and-retry an
28
- // interrupted request. Starting ctx.compact() here turns it into a manual
29
- // compaction (willRetry=false) and can consume Pi's overflow recovery, leaving
30
- // the agent idle. Let Pi handle every failed/aborted/overflow response; OM's
31
- // session_before_compact hook still supplies the actual memory compaction.
32
- const lastAssistant = [...event.messages].reverse().find(
33
- (m): m is Extract<typeof m, { role: "assistant" }> => m.role === "assistant",
34
- );
35
- if (
36
- lastAssistant
37
- && (
38
- lastAssistant.stopReason === "error"
39
- || lastAssistant.stopReason === "aborted"
40
- || isContextOverflow(lastAssistant, contextWindow)
41
- )
42
- ) return;
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
+ }
43
31
 
44
- const entries = ctx.sessionManager.getBranch() as Entry[];
45
- const tokens = rawTokensSinceLastCompaction(entries);
46
- // Resolve the proactive-compaction threshold from the active model's context
47
- // window when ratio mode is configured. ctx.model is the current session model
48
- // (Model<any> | undefined per ExtensionContext).
49
- threshold = resolveCompactAfterTokens(runtime.config, contextWindow > 0 ? contextWindow : undefined);
50
- if (tokens < threshold) return;
51
- }
32
+ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
33
+ registerCompactionResumeAcknowledgement(pi, runtime);
34
+ let resumeEmptyStopAfterProactiveCompaction = false;
52
35
 
53
- // Capture ctx properties synchronously the setTimeout + async work below
54
- // may outlive the extension ctx (stale after session replacement/reload).
36
+ const triggerCompaction = (ctx: any, options: TriggerOptions): void => {
37
+ const { origin, resume, threshold, shortContinuationPrompt } = options;
55
38
  const hasUI = ctx.hasUI;
56
39
  const ui = ctx.ui;
57
- const origin = agentRequested ? "agent-requested" : "proactive";
58
40
 
59
41
  runtime.compactInFlight = true;
60
42
  runtime.compactOrigin = origin;
@@ -63,9 +45,9 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
63
45
  if (!ctx.isIdle()) {
64
46
  runtime.compactInFlight = false;
65
47
  runtime.compactOrigin = undefined;
66
- if (agentRequested) runtime.compactRequested = true;
48
+ if (origin === "agent-requested") runtime.compactRequested = true;
67
49
  if (hasUI) ui?.notify(
68
- "Observational memory: compaction deferred — agent became busy before compaction",
50
+ "pi-contemplator: compaction deferred — agent became busy before compaction",
69
51
  "info",
70
52
  );
71
53
  return;
@@ -76,50 +58,108 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
76
58
  runtime.compactInFlight = false;
77
59
  runtime.compactOrigin = undefined;
78
60
  if (hasUI) ui?.notify(
79
- "Observational memory: compaction skipped — another compaction already ran before deferred compaction",
61
+ "pi-contemplator: compaction skipped — another compaction already ran before deferred compaction",
80
62
  "info",
81
63
  );
82
64
  return;
83
65
  }
84
66
  if (hasUI) {
85
- ui?.setStatus?.(COMPACTION_STATUS_KEY, `OM compaction: running (${origin}, resume pending)`);
86
- const reason = agentRequested ? "agent-requested, " : "";
67
+ const pending = resume ? ", resume pending" : "";
68
+ ui?.setStatus?.(COMPACTION_STATUS_KEY, `OM compaction: running (${origin}${pending})`);
69
+ const reason = origin === "agent-requested" ? "agent-requested, " : origin === "length-stop" ? "length-stop, " : "";
70
+ const continuation = resume ? "; the interrupted agent run will resume automatically" : "";
87
71
  ui?.notify(
88
- `Observational memory: compaction started (${reason}~${currentTokens.toLocaleString()} tokens); the agent will resume automatically`,
72
+ `pi-contemplator: compaction started (${reason}~${currentTokens.toLocaleString()} tokens)${continuation}`,
89
73
  "info",
90
74
  );
91
75
  }
92
- if (agentRequested) runtime.compactContinuationPrompt = undefined;
76
+ if (origin === "agent-requested") runtime.compactContinuationPrompt = undefined;
93
77
  ctx.compact({
94
78
  onComplete: () => {
95
79
  runtime.compactInFlight = false;
96
80
  runtime.compactOrigin = undefined;
97
- // Both explicit and proactive OM compactions are manual from Pi's
98
- // perspective (willRetry=false), so Pi will not continue either one.
99
- // Always enqueue a hidden continuation after OM finishes compacting.
100
- resumeAfterCompaction(pi, runtime, { hasUI, ui }, false, shortContinuationPrompt);
81
+ if (hasUI && !resume) ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
82
+ if (resume) resumeAfterCompaction(pi, runtime, { hasUI, ui }, false, shortContinuationPrompt);
101
83
  },
102
84
  onError: (error: { message: string }) => {
103
85
  runtime.compactInFlight = false;
104
86
  runtime.compactOrigin = undefined;
105
87
  if (hasUI) ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
106
88
  if (error.message !== "Compaction cancelled" && hasUI) {
107
- ui?.notify(`Observational memory: ${error.message}`, "error");
89
+ ui?.notify(`pi-contemplator: ${error.message}`, "error");
108
90
  }
109
- resumeAfterCompaction(pi, runtime, { hasUI, ui }, true, shortContinuationPrompt);
91
+ if (resume) resumeAfterCompaction(pi, runtime, { hasUI, ui }, true, shortContinuationPrompt);
110
92
  },
111
93
  });
112
94
  } catch (error) {
113
95
  runtime.compactInFlight = false;
114
- if (agentRequested) runtime.compactContinuationPrompt = undefined;
96
+ if (origin === "agent-requested") runtime.compactContinuationPrompt = undefined;
115
97
  runtime.compactOrigin = undefined;
116
98
  const msg = error instanceof Error ? error.message : String(error);
117
99
  if (hasUI) {
118
100
  ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
119
- ui?.notify(`Observational memory: compact threw: ${msg}`, "error");
101
+ ui?.notify(`pi-contemplator: compact threw: ${msg}`, "error");
120
102
  }
121
- resumeAfterCompaction(pi, runtime, { hasUI, ui }, true, shortContinuationPrompt);
103
+ if (resume) resumeAfterCompaction(pi, runtime, { hasUI, ui }, true, shortContinuationPrompt);
122
104
  }
123
105
  }, 0);
106
+ };
107
+
108
+ pi.on("agent_end", (event: any, ctx: any) => {
109
+ runtime.ensureConfig(ctx.cwd);
110
+ if (runtime.compactInFlight) return;
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
+
121
+ const agentRequested = runtime.compactRequested;
122
+ if (agentRequested) {
123
+ const shortContinuationPrompt = runtime.compactContinuationPrompt;
124
+ runtime.compactRequested = false;
125
+ triggerCompaction(ctx, { origin: "agent-requested", resume: true, shortContinuationPrompt });
126
+ return;
127
+ }
128
+ if (runtime.config.passive === true) return;
129
+
130
+ // Pi owns error, abort, and overflow retry policy. OM's session hook still
131
+ // supplies the compaction contents when Pi performs a native retry.
132
+ const contextWindow = typeof ctx.model?.contextWindow === "number" ? ctx.model.contextWindow : 0;
133
+ if (
134
+ !lastAssistant
135
+ || lastAssistant.stopReason === "error"
136
+ || lastAssistant.stopReason === "aborted"
137
+ || isContextOverflow(lastAssistant, contextWindow)
138
+ ) return;
139
+
140
+ // A non-overflow length stop is interrupted work, not a normal completed
141
+ // turn. Preserve the older compact-and-resume behavior only for this case.
142
+ if (lastAssistant.stopReason !== "length") return;
143
+ const entries = ctx.sessionManager.getBranch() as Entry[];
144
+ const threshold = resolveCompactAfterTokens(runtime.config, contextWindow > 0 ? contextWindow : undefined);
145
+ if (rawTokensSinceLastCompaction(entries) < threshold) return;
146
+ triggerCompaction(ctx, { origin: "length-stop", resume: true, threshold });
147
+ });
148
+
149
+ // Proactive threshold compaction is maintenance after Pi has fully settled.
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.
154
+ pi.on("agent_settled", (_event: any, ctx: any) => {
155
+ const resume = resumeEmptyStopAfterProactiveCompaction;
156
+ resumeEmptyStopAfterProactiveCompaction = false;
157
+ runtime.ensureConfig(ctx.cwd);
158
+ if (runtime.config.passive === true || runtime.compactInFlight || runtime.compactRequested) return;
159
+ const entries = ctx.sessionManager.getBranch() as Entry[];
160
+ const contextWindow = typeof ctx.model?.contextWindow === "number" ? ctx.model.contextWindow : undefined;
161
+ const threshold = resolveCompactAfterTokens(runtime.config, contextWindow);
162
+ if (rawTokensSinceLastCompaction(entries) < threshold) return;
163
+ triggerCompaction(ctx, { origin: "proactive", resume, threshold });
124
164
  });
125
165
  }