@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.
|
|
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": "
|
|
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.
|
|
52
|
-
"@earendil-works/pi-ai": "^0.
|
|
53
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
54
|
-
"@earendil-works/pi-tui": "^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
|
-
//
|
|
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"
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
25
|
-
|
|
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 (
|
|
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
|
-
|
|
86
|
-
|
|
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)
|
|
60
|
+
`Observational memory: compaction started (${reason}~${currentTokens.toLocaleString()} tokens)${continuation}`,
|
|
89
61
|
"info",
|
|
90
62
|
);
|
|
91
63
|
}
|
|
92
|
-
if (
|
|
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
|
-
|
|
98
|
-
|
|
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 (
|
|
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;
|