@luxass/pi-personal 0.0.0-stage → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lucas Nørgård
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,15 @@
1
- # Temporary Holding Version
1
+ # @luxass/pi-personal
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Collects [Pi](https://pi.dev) extensions that are tuned to a single personal workflow rather than general enough to ship on their own.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pi install npm:@luxass/pi-personal
9
+ ```
10
+
11
+ ## What it does
12
+
13
+ The package registers a `session_start` hook. When a session starts, Pi shows a notification confirming the extension loaded.
14
+
15
+ That is the whole of it today. The package is a home for defaults that suit one workflow, and each addition is meant to be edited in place as that workflow changes rather than configured through settings.
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@luxass/pi-personal",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Personal Pi extensions collected in one package, tuned to a single workflow.",
5
+ "keywords": [
6
+ "personal",
7
+ "pi",
8
+ "pi-package"
9
+ ],
10
+ "license": "MIT",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/luxass/pi-extensions",
14
+ "directory": "packages/personal"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "type": "module",
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "peerDependencies": {
26
+ "@earendil-works/pi-ai": "*",
27
+ "@earendil-works/pi-coding-agent": "*",
28
+ "@earendil-works/pi-tui": "*"
29
+ },
30
+ "pi": {
31
+ "image": "https://raw.githubusercontent.com/luxass/pi-extensions/main/packages/personal/docs/cover.png",
32
+ "extensions": [
33
+ "./src/continue-after-compaction.ts",
34
+ "./src/git-interceptor.ts",
35
+ "./src/package-manager-interceptor.ts",
36
+ "./src/worker-configuration-guard.ts",
37
+ "./src/custom-header.ts",
38
+ "./src/crow/index.ts"
39
+ ]
40
+ },
41
+ "scripts": {
42
+ "lint": "oxlint --deny-warnings .",
43
+ "lint:fix": "oxlint --fix --deny-warnings .",
44
+ "fmt": "oxfmt .",
45
+ "fmt:check": "oxfmt --check .",
46
+ "typecheck": "tsc -p tsconfig.json"
47
+ }
6
48
  }
@@ -0,0 +1,59 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ const buildContinuationPrompt = (
4
+ sessionFile: string | undefined,
5
+ compactionEntryId: string,
6
+ ): string => {
7
+ const sessionSource =
8
+ sessionFile === undefined
9
+ ? "This session is ephemeral, so no persisted session file is available."
10
+ : [
11
+ `The persisted session JSONL is ${JSON.stringify(sessionFile)}.`,
12
+ "Inspect it directly with the read and bash tools.",
13
+ "Do not launch a nested Pi process or open the session with `pi --session`.",
14
+ ].join(" ");
15
+
16
+ return `Compaction has just completed. Resume the existing task rather than waiting for another user prompt.
17
+
18
+ ${sessionSource}
19
+ The new compaction entry ID is ${JSON.stringify(compactionEntryId)}.
20
+
21
+ Before continuing:
22
+
23
+ 1. Review the active session branch leading to the compaction entry. Focus first on messages and tool calls immediately before compaction, searching earlier history only as needed. Remember that JSONL append order can include abandoned branches, so follow parentId links rather than blindly treating every entry as active.
24
+ 2. Reconstruct the original goal, user constraints, decisions made, files changed, commands and tests run, unresolved issues, and intended next action.
25
+ 3. Reconcile the recovered history with the compaction summary and current repository state. Treat the current worktree as authoritative for file state and the original session history as authoritative for user intent.
26
+ 4. Briefly state the context you recovered.
27
+ 5. Immediately perform the next unfinished step. Do not stop after the recap and do not ask the user to repeat prior context unless the session data is genuinely unavailable or ambiguous.`;
28
+ };
29
+
30
+ /**
31
+ * Automatically resumes work after every successful Pi compaction.
32
+ *
33
+ * The continuation is deferred by one event-loop turn so manual compaction can
34
+ * finish reconnecting the agent runtime before a new prompt begins. During an
35
+ * active automatic-compaction recovery, it is delivered as steering so the
36
+ * continuation is read before the retried agent performs more work.
37
+ */
38
+ export default function continueAfterCompaction(pi: ExtensionAPI): void {
39
+ const pendingTimers = new Set<ReturnType<typeof setTimeout>>();
40
+
41
+ pi.on("session_compact", (event, ctx) => {
42
+ const sessionFile = ctx.sessionManager.getSessionFile();
43
+ const prompt = buildContinuationPrompt(sessionFile, event.compactionEntry.id);
44
+
45
+ const timer = setTimeout(() => {
46
+ pendingTimers.delete(timer);
47
+ pi.sendUserMessage(prompt, { deliverAs: "steer" });
48
+ }, 0);
49
+
50
+ pendingTimers.add(timer);
51
+ });
52
+
53
+ pi.on("session_shutdown", () => {
54
+ for (const timer of pendingTimers) {
55
+ clearTimeout(timer);
56
+ }
57
+ pendingTimers.clear();
58
+ });
59
+ }
@@ -0,0 +1,318 @@
1
+ import {
2
+ type Api,
3
+ type AssistantMessage,
4
+ createAssistantMessageEventStream,
5
+ type Model,
6
+ } from "@earendil-works/pi-ai";
7
+ import { type ExtensionAPI, SettingsManager } from "@earendil-works/pi-coding-agent";
8
+
9
+ import type { CliproxyCodexStreamSimple, CloseCodexWebSocketSessions } from "./codex-stream.ts";
10
+
11
+ export const PROACTIVE_COMPACTION_ERROR_PREFIX =
12
+ "context_length_exceeded: proactive compaction threshold reached";
13
+
14
+ export interface ProactiveCompactionSettings {
15
+ enabled: boolean;
16
+ reserveTokens: number;
17
+ }
18
+
19
+ export type { CloseCodexWebSocketSessions };
20
+
21
+ const DEFAULT_COMPACTION_ENABLED = true;
22
+ const DEFAULT_COMPACTION_RESERVE_TOKENS = 16384;
23
+
24
+ interface CompatibleSettingsManager {
25
+ reload?: () => Promise<void> | void;
26
+ reloadFromDisk?: () => Promise<void> | void;
27
+ getCompactionSettings?: () => { enabled?: unknown; reserveTokens?: unknown };
28
+ get?: (key: string) => unknown;
29
+ }
30
+
31
+ function isCompatibleSettingsManager(value: unknown): value is CompatibleSettingsManager {
32
+ return typeof value === "object" && value !== null;
33
+ }
34
+
35
+ function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
36
+ return (
37
+ ((typeof value === "object" && value !== null) || typeof value === "function") &&
38
+ typeof (value as { then?: unknown }).then === "function"
39
+ );
40
+ }
41
+
42
+ function normalizeCompactionSettings(
43
+ enabled: unknown,
44
+ reserveTokens: unknown,
45
+ ): ProactiveCompactionSettings {
46
+ return {
47
+ enabled: typeof enabled === "boolean" ? enabled : DEFAULT_COMPACTION_ENABLED,
48
+ reserveTokens:
49
+ typeof reserveTokens === "number" && Number.isFinite(reserveTokens) && reserveTokens >= 0
50
+ ? reserveTokens
51
+ : DEFAULT_COMPACTION_RESERVE_TOKENS,
52
+ };
53
+ }
54
+
55
+ function readCompactionSettings(
56
+ manager: CompatibleSettingsManager,
57
+ ): ProactiveCompactionSettings | undefined {
58
+ if (typeof manager.getCompactionSettings === "function") {
59
+ try {
60
+ const settings = manager.getCompactionSettings();
61
+ return normalizeCompactionSettings(settings?.enabled, settings?.reserveTokens);
62
+ } catch {
63
+ // Fall through to OMP's key-based settings API when available.
64
+ }
65
+ }
66
+ if (typeof manager.get === "function") {
67
+ try {
68
+ return normalizeCompactionSettings(
69
+ manager.get("compaction.enabled"),
70
+ manager.get("compaction.reserveTokens"),
71
+ );
72
+ } catch {
73
+ return undefined;
74
+ }
75
+ }
76
+ return undefined;
77
+ }
78
+
79
+ async function reloadSettings(manager: CompatibleSettingsManager): Promise<void> {
80
+ if (typeof manager.reload === "function") {
81
+ try {
82
+ await manager.reload();
83
+ } catch {
84
+ // Keep the last successfully read settings when disk reload fails.
85
+ }
86
+ return;
87
+ }
88
+ if (typeof manager.reloadFromDisk === "function") {
89
+ try {
90
+ await manager.reloadFromDisk();
91
+ } catch {
92
+ // Keep the last successfully read settings when disk reload fails.
93
+ }
94
+ }
95
+ }
96
+
97
+ /** Session id used to key pi-ai's reused Codex WebSocket. */
98
+ export function resolveCompactionSessionId(source?: {
99
+ sessionId?: unknown;
100
+ sessionManager?: { getSessionId?: () => unknown };
101
+ }): string | undefined {
102
+ const fromManager = source?.sessionManager?.getSessionId?.();
103
+ if (typeof fromManager === "string" && fromManager.trim()) {
104
+ return fromManager;
105
+ }
106
+ if (typeof source?.sessionId === "string" && source.sessionId.trim()) {
107
+ return source.sessionId;
108
+ }
109
+ return undefined;
110
+ }
111
+
112
+ export function shouldScheduleProactiveCompaction(
113
+ message: AssistantMessage,
114
+ contextTokens: number,
115
+ contextWindow: number,
116
+ settings: ProactiveCompactionSettings,
117
+ ): boolean {
118
+ if (!settings.enabled || message.stopReason !== "toolUse") {
119
+ return false;
120
+ }
121
+ if (!message.content.some((block) => block.type === "toolCall")) {
122
+ return false;
123
+ }
124
+ if (!Number.isFinite(contextTokens) || contextTokens <= 0) {
125
+ return false;
126
+ }
127
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) {
128
+ return false;
129
+ }
130
+ if (!Number.isFinite(settings.reserveTokens) || settings.reserveTokens < 0) {
131
+ return false;
132
+ }
133
+
134
+ return contextTokens > contextWindow - settings.reserveTokens;
135
+ }
136
+
137
+ export function createProactiveCompactionStream(
138
+ model: Model<Api>,
139
+ contextTokens: number,
140
+ threshold: number,
141
+ ) {
142
+ const stream = createAssistantMessageEventStream();
143
+ const output: AssistantMessage = {
144
+ role: "assistant",
145
+ content: [],
146
+ api: model.api,
147
+ provider: model.provider,
148
+ model: model.id,
149
+ usage: {
150
+ input: 0,
151
+ output: 0,
152
+ cacheRead: 0,
153
+ cacheWrite: 0,
154
+ totalTokens: 0,
155
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
156
+ },
157
+ stopReason: "error",
158
+ errorMessage: `${PROACTIVE_COMPACTION_ERROR_PREFIX} (${contextTokens} > ${threshold})`,
159
+ timestamp: Date.now(),
160
+ };
161
+
162
+ queueMicrotask(() => {
163
+ stream.push({ type: "start", partial: output });
164
+ stream.push({ type: "error", reason: "error", error: output });
165
+ stream.end();
166
+ });
167
+
168
+ return stream;
169
+ }
170
+
171
+ export class ProactiveCompactionController {
172
+ private settingsManager: CompatibleSettingsManager | undefined;
173
+ private cachedCompactionSettings: ProactiveCompactionSettings | undefined;
174
+ private pending: { modelKey: string; contextTokens: number; threshold: number } | undefined;
175
+ private closeWebSocketSessions: CloseCodexWebSocketSessions | undefined;
176
+
177
+ constructor(
178
+ private readonly agentDir: string,
179
+ private readonly providerId: string,
180
+ closeWebSocketSessions?: CloseCodexWebSocketSessions,
181
+ ) {
182
+ this.closeWebSocketSessions = closeWebSocketSessions;
183
+ }
184
+
185
+ setCloseWebSocketSessions(closeWebSocketSessions: CloseCodexWebSocketSessions): void {
186
+ this.closeWebSocketSessions = closeWebSocketSessions;
187
+ }
188
+
189
+ register(pi: ExtensionAPI): void {
190
+ pi.on("session_start", async (_event, ctx) => {
191
+ this.cachedCompactionSettings = undefined;
192
+ try {
193
+ const created: unknown = SettingsManager.create(ctx.cwd, this.agentDir, {
194
+ projectTrusted: ctx.isProjectTrusted(),
195
+ });
196
+ const manager = isPromiseLike(created) ? await created : created;
197
+ this.settingsManager = isCompatibleSettingsManager(manager) ? manager : undefined;
198
+ this.cachedCompactionSettings = this.settingsManager
199
+ ? readCompactionSettings(this.settingsManager)
200
+ : undefined;
201
+ } catch {
202
+ this.settingsManager = undefined;
203
+ }
204
+ this.pending = undefined;
205
+ });
206
+
207
+ pi.on("session_shutdown", () => {
208
+ this.settingsManager = undefined;
209
+ this.cachedCompactionSettings = undefined;
210
+ this.pending = undefined;
211
+ });
212
+
213
+ pi.on("session_before_compact", () => {
214
+ // Compaction summaries use the same provider stream. Clear the synthetic
215
+ // overflow before pi invokes the summarizer so it cannot abort compaction.
216
+ this.pending = undefined;
217
+ });
218
+
219
+ pi.on("session_compact", (_event, ctx) => {
220
+ this.pending = undefined;
221
+ // CLIProxyAPI binds server-side Codex context to the WebSocket. Compaction
222
+ // only rewrites the client message list, so reuse would keep cacheRead high
223
+ // and retrigger proactive compaction on a now-small session.
224
+ this.resetWebSocketSession(resolveCompactionSessionId(ctx));
225
+ });
226
+
227
+ pi.on("agent_settled", () => {
228
+ this.pending = undefined;
229
+ });
230
+
231
+ pi.on("turn_end", async (event, ctx) => {
232
+ const message = event.message;
233
+ if (message.role !== "assistant" || message.provider !== this.providerId) {
234
+ return;
235
+ }
236
+ if (!ctx.model || ctx.model.provider !== this.providerId || ctx.model.id !== message.model) {
237
+ return;
238
+ }
239
+
240
+ const settingsManager = this.settingsManager;
241
+ if (!settingsManager) {
242
+ return;
243
+ }
244
+ await reloadSettings(settingsManager);
245
+ const refreshedSettings = readCompactionSettings(settingsManager);
246
+ if (refreshedSettings) {
247
+ this.cachedCompactionSettings = refreshedSettings;
248
+ }
249
+ const settings = refreshedSettings ?? this.cachedCompactionSettings;
250
+ if (!settings) {
251
+ return;
252
+ }
253
+ const contextTokens = ctx.getContextUsage()?.tokens;
254
+ if (contextTokens === null || contextTokens === undefined) {
255
+ return;
256
+ }
257
+ if (
258
+ !shouldScheduleProactiveCompaction(
259
+ message,
260
+ contextTokens,
261
+ ctx.model.contextWindow,
262
+ settings,
263
+ )
264
+ ) {
265
+ return;
266
+ }
267
+
268
+ this.pending = {
269
+ modelKey: this.modelKey(ctx.model),
270
+ contextTokens,
271
+ threshold: ctx.model.contextWindow - settings.reserveTokens,
272
+ };
273
+ });
274
+ }
275
+
276
+ getCompactionSettings(): ProactiveCompactionSettings | undefined {
277
+ const current = this.settingsManager ? readCompactionSettings(this.settingsManager) : undefined;
278
+ if (current) {
279
+ this.cachedCompactionSettings = current;
280
+ }
281
+ return current ?? this.cachedCompactionSettings;
282
+ }
283
+
284
+ wrapStreamSimple(streamSimple: CliproxyCodexStreamSimple): CliproxyCodexStreamSimple {
285
+ return (model, context, options) => {
286
+ const pending = this.pending;
287
+ if (!pending || pending.modelKey !== this.modelKey(model)) {
288
+ return streamSimple(model, context, options);
289
+ }
290
+
291
+ this.pending = undefined;
292
+ this.resetWebSocketSession(options?.sessionId);
293
+ return createProactiveCompactionStream(model, pending.contextTokens, pending.threshold);
294
+ };
295
+ }
296
+
297
+ private resetWebSocketSession(sessionId?: string): void {
298
+ if (!sessionId || !this.closeWebSocketSessions) {
299
+ return;
300
+ }
301
+ try {
302
+ this.closeWebSocketSessions(sessionId);
303
+ } catch (error) {
304
+ const message = error instanceof Error ? error.message : String(error);
305
+ process.stderr.write(
306
+ `${JSON.stringify({
307
+ source: "pi-cliproxyapi-provider",
308
+ level: "warning",
309
+ message: `Failed to close Codex WebSocket for session ${sessionId}: ${message}`,
310
+ })}\n`,
311
+ );
312
+ }
313
+ }
314
+
315
+ private modelKey(model: Pick<Model<Api>, "provider" | "id">): string {
316
+ return `${model.provider}/${model.id}`;
317
+ }
318
+ }