@matthewfl/pi-contemplator 0.0.8 → 0.0.10

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
@@ -190,9 +190,15 @@ Model selection, trigger thresholds, passive mode (which stops all background wo
190
190
  ```bash
191
191
  npm install
192
192
  npm test
193
+ npm run test:unit
194
+ npm run test:e2e
193
195
  npm run typecheck
194
196
  ```
195
197
 
198
+ `npm test` runs both the unit and RPC end-to-end suites. Use `test:unit` or `test:e2e` to run either suite independently.
199
+
200
+ `test:e2e` starts a local OpenAI-compatible model server and launches isolated real Pi CLI processes in RPC mode while mocking only the external model-server boundary. The suites cover observer, reflector, dropper, contemplator, reviewer, primary-agent, memory-tool, and compaction flows. They exercise slow and parallel tools, probe races and feedback, hidden/idle delivery, cumulative activity time, process restore, reviewer transcript resume and budget exhaustion, accepted/rejected reviews, manual/failed compaction continuations and non-restarting proactive compaction, compaction observer sidecars, malformed memory records, huge-source bounding, id collisions, role-specific model routing, feature flags, session-tree forks, and concurrent session isolation.
201
+
196
202
  ## License
197
203
 
198
204
  MIT. See [LICENSE](LICENSE).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@matthewfl/pi-contemplator",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "description": "A Pi extension that keeps long-running agentic sessions on track with background memory, contemplation, and structural review.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,7 +39,9 @@
39
39
  ],
40
40
  "scripts": {
41
41
  "typecheck": "tsc --noEmit",
42
- "test": "vitest run"
42
+ "test": "npm run test:unit && npm run test:e2e",
43
+ "test:unit": "vitest run",
44
+ "test:e2e": "node tests/e2e/rpc-contemplator.mjs && node tests/e2e/rpc-delivery.mjs && node tests/e2e/rpc-restore-review.mjs && node tests/e2e/rpc-compaction.mjs && node tests/e2e/rpc-compaction-resilience.mjs && node tests/e2e/rpc-memory-edges.mjs && node tests/e2e/rpc-routing-isolation.mjs"
43
45
  },
44
46
  "peerDependencies": {
45
47
  "@earendil-works/pi-agent-core": "*",
@@ -48,10 +50,10 @@
48
50
  "@earendil-works/pi-tui": "*"
49
51
  },
50
52
  "devDependencies": {
51
- "@earendil-works/pi-agent-core": "^0.81.0",
52
- "@earendil-works/pi-ai": "^0.81.0",
53
- "@earendil-works/pi-coding-agent": "^0.81.0",
54
- "@earendil-works/pi-tui": "^0.81.0",
53
+ "@earendil-works/pi-agent-core": "^0.84.2",
54
+ "@earendil-works/pi-ai": "^0.84.2",
55
+ "@earendil-works/pi-coding-agent": "^0.84.2",
56
+ "@earendil-works/pi-tui": "^0.84.2",
55
57
  "@types/node": "^22.0.0",
56
58
  "typebox": "^1.1.38",
57
59
  "typescript": "^5.6.0",
@@ -670,20 +670,23 @@ export class Contemplator {
670
670
  // designed for agents that run for hours; a probe must be injected after
671
671
  // the current tool-call batch, before the very next model request. Never
672
672
  // change this to nextTurn: that can postpone delivery until a user prompt.
673
- // triggerTurn stays false so a probe never starts an extra agent turn.
673
+ // IMPORTANT: omit triggerTurn entirely. Pi 0.84+ interprets an explicit
674
+ // triggerTurn:false as "do not queue while streaming" and inserts directly
675
+ // into agent.state, outside the active run's context snapshot. Omitting it
676
+ // still does not start a turn while idle, but allows steer to work in-run.
674
677
  if (this.agentActiveSince !== undefined) this.queuedProbeIds.add(probeId);
675
678
  this.pi.sendMessage({
676
679
  customType: CONTEMPLATOR_SUGGESTION,
677
680
  content: `Background contemplator probe (advisory):\n${question}`,
678
681
  display: this.runtime.config.showContemplatorMessages,
679
682
  details: { version: 1, question, source, probeId },
680
- }, { deliverAs: "steer", triggerTurn: false });
683
+ }, { deliverAs: "steer" });
681
684
  debugLog("contemplator.suggestion_queued", {
682
685
  probeId,
683
686
  suggestionLength: question.length,
684
687
  delivery: "pi.sendMessage",
685
688
  deliverAs: "steer",
686
- triggerTurn: false,
689
+ triggerTurn: "omitted",
687
690
  source,
688
691
  });
689
692
  }
@@ -751,7 +754,9 @@ export class Contemplator {
751
754
  debugLog(result.outcome === "proposal" ? "reviewer.proposal_created" : "reviewer.no_proposal", { reviewRequestId: request.id, reviewMemoryId: result.id, scope: result.scope });
752
755
  if (result.outcome === "proposal") {
753
756
  const notice = `BACKGROUND ${result.scope.toUpperCase()} REVIEW PROPOSAL [${result.id}]\n\n${result.summary}\n\nRecall memory [${result.id}] to read the full conceptual proposal when it is relevant.\n\nThis is advisory. Evaluate it against the actual environment and current work.`;
754
- this.pi.sendMessage({ customType: REVIEW_PROPOSAL_MESSAGE, content: notice, display: this.runtime.config.showContemplatorMessages, details: { version: 1, reviewRequestId: request.id, reviewMemoryId: result.id, scope: result.scope } }, { deliverAs: "steer", triggerTurn: false });
757
+ // As with probes, triggerTurn must be omitted or Pi 0.84+ bypasses
758
+ // the streaming steer queue and the active run never sees this message.
759
+ this.pi.sendMessage({ customType: REVIEW_PROPOSAL_MESSAGE, content: notice, display: this.runtime.config.showContemplatorMessages, details: { version: 1, reviewRequestId: request.id, reviewMemoryId: result.id, scope: result.scope } }, { deliverAs: "steer" });
755
760
  this.pi.appendEntry(OM_REVIEWER_NOTICE, { version: 1, reviewRequestId: request.id, reviewMemoryId: result.id, scope: result.scope, content: notice });
756
761
  this.markTipPersisted(ctx);
757
762
  debugLog("reviewer.primary_notice_queued", { reviewRequestId: request.id, reviewMemoryId: result.id });
@@ -29,10 +29,11 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
29
29
 
30
30
  const initiatedByOm = runtime.compactInFlight && event.reason === "manual";
31
31
  const reason = initiatedByOm ? (runtime.compactOrigin ?? "proactive") : event.reason;
32
+ const omWillResume = initiatedByOm && (runtime.compactOrigin === "agent-requested" || runtime.compactOrigin === "length-stop");
32
33
  if (ctx.hasUI) {
33
34
  let pending = "";
34
35
  if (event.willRetry) pending = ", retry pending";
35
- else if (initiatedByOm) pending = ", resume pending";
36
+ else if (omWillResume) pending = ", resume pending";
36
37
  ctx.ui.setStatus?.(COMPACTION_STATUS_KEY, `OM compaction: running (${reason}${pending})`);
37
38
  if (!initiatedByOm) {
38
39
  const continuation = event.willRetry ? "; the interrupted agent run will resume automatically" : "";
@@ -88,12 +89,13 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
88
89
  pi.on("session_compact", (event: any, ctx: any) => {
89
90
  const initiatedByOm = runtime.compactInFlight && event.reason === "manual";
90
91
  const reason = initiatedByOm ? (runtime.compactOrigin ?? "proactive") : event.reason;
92
+ const omWillResume = initiatedByOm && (runtime.compactOrigin === "agent-requested" || runtime.compactOrigin === "length-stop");
91
93
  if (event.willRetry) watchForNativeCompactionResume(pi, runtime, ctx);
92
94
  if (!ctx.hasUI) return;
93
95
  ctx.ui.setStatus?.(COMPACTION_STATUS_KEY, undefined);
94
96
  let continuation = "";
95
97
  if (event.willRetry) continuation = "; resuming the interrupted agent run";
96
- else if (initiatedByOm) continuation = "; resuming the agent run";
98
+ else if (omWillResume) continuation = "; resuming the agent run";
97
99
  ctx.ui.notify(`Observational memory: compaction complete (${reason})${continuation}`, "info");
98
100
  });
99
101
  }
@@ -9,52 +9,22 @@ 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";
13
+
14
+ type TriggerOptions = {
15
+ origin: CompactionOrigin;
16
+ resume: boolean;
17
+ threshold?: number;
18
+ shortContinuationPrompt?: string;
19
+ };
12
20
 
13
21
  export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
14
22
  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;
23
23
 
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;
43
-
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
- }
52
-
53
- // Capture ctx properties synchronously — the setTimeout + async work below
54
- // may outlive the extension ctx (stale after session replacement/reload).
24
+ const triggerCompaction = (ctx: any, options: TriggerOptions): void => {
25
+ const { origin, resume, threshold, shortContinuationPrompt } = options;
55
26
  const hasUI = ctx.hasUI;
56
27
  const ui = ctx.ui;
57
- const origin = agentRequested ? "agent-requested" : "proactive";
58
28
 
59
29
  runtime.compactInFlight = true;
60
30
  runtime.compactOrigin = origin;
@@ -63,7 +33,7 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
63
33
  if (!ctx.isIdle()) {
64
34
  runtime.compactInFlight = false;
65
35
  runtime.compactOrigin = undefined;
66
- if (agentRequested) runtime.compactRequested = true;
36
+ if (origin === "agent-requested") runtime.compactRequested = true;
67
37
  if (hasUI) ui?.notify(
68
38
  "Observational memory: compaction deferred — agent became busy before compaction",
69
39
  "info",
@@ -82,22 +52,22 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
82
52
  return;
83
53
  }
84
54
  if (hasUI) {
85
- ui?.setStatus?.(COMPACTION_STATUS_KEY, `OM compaction: running (${origin}, resume pending)`);
86
- const reason = agentRequested ? "agent-requested, " : "";
55
+ const pending = resume ? ", resume pending" : "";
56
+ ui?.setStatus?.(COMPACTION_STATUS_KEY, `OM compaction: running (${origin}${pending})`);
57
+ const reason = origin === "agent-requested" ? "agent-requested, " : origin === "length-stop" ? "length-stop, " : "";
58
+ const continuation = resume ? "; the interrupted agent run will resume automatically" : "";
87
59
  ui?.notify(
88
- `Observational memory: compaction started (${reason}~${currentTokens.toLocaleString()} tokens); the agent will resume automatically`,
60
+ `Observational memory: compaction started (${reason}~${currentTokens.toLocaleString()} tokens)${continuation}`,
89
61
  "info",
90
62
  );
91
63
  }
92
- if (agentRequested) runtime.compactContinuationPrompt = undefined;
64
+ if (origin === "agent-requested") runtime.compactContinuationPrompt = undefined;
93
65
  ctx.compact({
94
66
  onComplete: () => {
95
67
  runtime.compactInFlight = false;
96
68
  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);
69
+ if (hasUI && !resume) ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
70
+ if (resume) resumeAfterCompaction(pi, runtime, { hasUI, ui }, false, shortContinuationPrompt);
101
71
  },
102
72
  onError: (error: { message: string }) => {
103
73
  runtime.compactInFlight = false;
@@ -106,20 +76,68 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
106
76
  if (error.message !== "Compaction cancelled" && hasUI) {
107
77
  ui?.notify(`Observational memory: ${error.message}`, "error");
108
78
  }
109
- resumeAfterCompaction(pi, runtime, { hasUI, ui }, true, shortContinuationPrompt);
79
+ if (resume) resumeAfterCompaction(pi, runtime, { hasUI, ui }, true, shortContinuationPrompt);
110
80
  },
111
81
  });
112
82
  } catch (error) {
113
83
  runtime.compactInFlight = false;
114
- if (agentRequested) runtime.compactContinuationPrompt = undefined;
84
+ if (origin === "agent-requested") runtime.compactContinuationPrompt = undefined;
115
85
  runtime.compactOrigin = undefined;
116
86
  const msg = error instanceof Error ? error.message : String(error);
117
87
  if (hasUI) {
118
88
  ui?.setStatus?.(COMPACTION_STATUS_KEY, undefined);
119
89
  ui?.notify(`Observational memory: compact threw: ${msg}`, "error");
120
90
  }
121
- resumeAfterCompaction(pi, runtime, { hasUI, ui }, true, shortContinuationPrompt);
91
+ if (resume) resumeAfterCompaction(pi, runtime, { hasUI, ui }, true, shortContinuationPrompt);
122
92
  }
123
93
  }, 0);
94
+ };
95
+
96
+ pi.on("agent_end", (event: any, ctx: any) => {
97
+ runtime.ensureConfig(ctx.cwd);
98
+ if (runtime.compactInFlight) return;
99
+
100
+ const agentRequested = runtime.compactRequested;
101
+ if (agentRequested) {
102
+ const shortContinuationPrompt = runtime.compactContinuationPrompt;
103
+ runtime.compactRequested = false;
104
+ triggerCompaction(ctx, { origin: "agent-requested", resume: true, shortContinuationPrompt });
105
+ return;
106
+ }
107
+ if (runtime.config.passive === true) return;
108
+
109
+ // Pi owns error, abort, and overflow retry policy. OM's session hook still
110
+ // 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
+ const contextWindow = typeof ctx.model?.contextWindow === "number" ? ctx.model.contextWindow : 0;
115
+ if (
116
+ !lastAssistant
117
+ || lastAssistant.stopReason === "error"
118
+ || lastAssistant.stopReason === "aborted"
119
+ || isContextOverflow(lastAssistant, contextWindow)
120
+ ) return;
121
+
122
+ // A non-overflow length stop is interrupted work, not a normal completed
123
+ // turn. Preserve the older compact-and-resume behavior only for this case.
124
+ if (lastAssistant.stopReason !== "length") return;
125
+ const entries = ctx.sessionManager.getBranch() as Entry[];
126
+ const threshold = resolveCompactAfterTokens(runtime.config, contextWindow > 0 ? contextWindow : undefined);
127
+ if (rawTokensSinceLastCompaction(entries) < threshold) return;
128
+ triggerCompaction(ctx, { origin: "length-stop", resume: true, threshold });
129
+ });
130
+
131
+ // 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.
134
+ pi.on("agent_settled", (_event: any, ctx: any) => {
135
+ runtime.ensureConfig(ctx.cwd);
136
+ if (runtime.config.passive === true || runtime.compactInFlight || runtime.compactRequested) return;
137
+ const entries = ctx.sessionManager.getBranch() as Entry[];
138
+ const contextWindow = typeof ctx.model?.contextWindow === "number" ? ctx.model.contextWindow : undefined;
139
+ const threshold = resolveCompactAfterTokens(runtime.config, contextWindow);
140
+ if (rawTokensSinceLastCompaction(entries) < threshold) return;
141
+ triggerCompaction(ctx, { origin: "proactive", resume: false, threshold });
124
142
  });
125
143
  }
package/src/runtime.ts CHANGED
@@ -147,7 +147,7 @@ export class Runtime {
147
147
  compactRequested = false;
148
148
  /** Agent-authored instructions to deliver after an explicit compact_context request. */
149
149
  compactContinuationPrompt: string | undefined;
150
- compactOrigin: "proactive" | "agent-requested" | undefined;
150
+ compactOrigin: "proactive" | "agent-requested" | "length-stop" | undefined;
151
151
  compactHookInFlight = false;
152
152
  compactionResumePending = false;
153
153
  compactionResumeGeneration = 0;