Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ Managed ownership records the directory name and whether Pi created the local br

The LLM-callable `worktree` tool provides the same `name`, `repository`, `branch`, `startPoint`, and `existing` flows. For a pull request URL, agents must resolve the PR's real head branch and fetched remote-tracking ref and pass them explicitly rather than deriving a branch from a directory such as `pr-30`. The tool queues a correlated `/worktree` follow-up, ends the old run, verifies the replacement, and resumes its continuation there. Create-only requests that should not enter the checkout remain ordinary Git operations.

## Context clearing

Run `/clear` to exclude the conversation so far from subsequent model requests without removing it from the session transcript. Pi and Pi Web both show `Context cleared.` when the new context boundary is active.

## Web sessions

`extensions/web-sessions.ts` connects every running Pi session to a local Bun server. The first Pi process starts the server on `127.0.0.1:31415`; later processes discover it through `~/.pi/agent/web/server.json` and attach their own live event streams.
Expand Down Expand Up @@ -234,5 +238,5 @@ bun install --frozen-lockfile
bun run check
bun test
bun run webBuild
pi -e ./extensions/session-footer.ts -e ./extensions/pr-footer.ts -e ./extensions/subagents.ts -e ./extensions/worktree.ts -e ./extensions/web-sessions.ts -e ./extensions/auto-router.ts
pi -e ./extensions/session-footer.ts -e ./extensions/pr-footer.ts -e ./extensions/subagents.ts -e ./extensions/worktree.ts -e ./extensions/web-sessions.ts -e ./extensions/auto-router.ts -e ./extensions/clear-context.ts
```
184 changes: 184 additions & 0 deletions extensions/clear-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import {
type ContextEvent,
type ExtensionAPI,
estimateTokens,
findCutPoint,
generateSummaryWithUsage,
type SessionEntry,
sessionEntryToContextMessages,
} from "@earendil-works/pi-coding-agent";
import { WEB_CLEAR_CONTEXT_ENTRY } from "../web/clear-command.js";

export const CLEAR_CONTEXT_ENTRY = WEB_CLEAR_CONTEXT_ENTRY;
export const CLEAR_CONTEXT_MATERIALIZED_ENTRY =
"vessup:clear-context-materialized";
export const CLEAR_CONTEXT_COMPLETE_MESSAGE = "Context cleared.";
const CLEAR_COMPACTION_DETAIL = "clearContextBoundary";

function latestActiveClear(
entries: readonly SessionEntry[],
): SessionEntry | undefined {
for (let index = entries.length - 1; index >= 0; index -= 1) {
const entry = entries[index];
if (entry?.type !== "custom") continue;
if (entry.customType === CLEAR_CONTEXT_MATERIALIZED_ENTRY) return undefined;
if (entry.customType === CLEAR_CONTEXT_ENTRY) return entry;
}
return undefined;
}

/** Add a durable boundary after which earlier conversation is excluded from LLM context. */
export function clearSessionContext(
pi: Pick<ExtensionAPI, "appendEntry">,
): void {
pi.appendEntry(CLEAR_CONTEXT_ENTRY);
}

/** Preserve the transcript while returning only context messages after the latest clear boundary. */
export function contextAfterLatestClear(
entries: readonly SessionEntry[],
): ContextEvent["messages"] | undefined {
const clear = latestActiveClear(entries);
if (!clear) return undefined;
const clearIndex = entries.indexOf(clear);
return entries
.slice(clearIndex + 1)
.flatMap((entry) => sessionEntryToContextMessages(entry));
}

function preparePostClearCompaction(
entries: SessionEntry[],
settings: { keepRecentTokens: number; reserveTokens: number },
):
| {
firstKeptEntryId: string;
messagesToSummarize: ContextEvent["messages"];
turnPrefixMessages: ContextEvent["messages"];
tokensBefore: number;
settings: typeof settings;
}
| undefined {
const cutPoint = findCutPoint(
entries,
0,
entries.length,
settings.keepRecentTokens,
);
const firstKeptEntry = entries[cutPoint.firstKeptEntryIndex];
if (!firstKeptEntry?.id) return undefined;
const historyEnd = cutPoint.isSplitTurn
? cutPoint.turnStartIndex
: cutPoint.firstKeptEntryIndex;
const messagesToSummarize = entries
.slice(0, historyEnd)
.flatMap((entry) => sessionEntryToContextMessages(entry));
const turnPrefixMessages = cutPoint.isSplitTurn
? entries
.slice(cutPoint.turnStartIndex, cutPoint.firstKeptEntryIndex)
.flatMap((entry) => sessionEntryToContextMessages(entry))
: [];
if (messagesToSummarize.length === 0 && turnPrefixMessages.length === 0)
return undefined;
return {
firstKeptEntryId: firstKeptEntry.id,
messagesToSummarize,
turnPrefixMessages,
tokensBefore: entries
.flatMap((entry) => sessionEntryToContextMessages(entry))
.reduce((total, message) => total + estimateTokens(message), 0),
settings,
};
}

export default function clearContextExtension(pi: ExtensionAPI): void {
pi.registerCommand("clear", {
description: "Clear conversation context while keeping the transcript",
handler: async (args, ctx) => {
if (args.trim()) {
ctx.ui.notify("/clear does not accept arguments", "error");
return;
}
await ctx.waitForIdle();
clearSessionContext(pi);
ctx.ui.notify(CLEAR_CONTEXT_COMPLETE_MESSAGE, "info");
},
});

pi.on("context", (_event, ctx) => {
const messages = contextAfterLatestClear(
ctx.sessionManager.buildContextEntries(),
);
return messages === undefined ? undefined : { messages };
});

// Pi prepares compaction from the raw branch rather than the context hook's
// filtered messages. Rebuild preparation from the post-clear branch so neither
// automatic nor explicit compaction can summarize pre-clear text.
pi.on("session_before_compact", async (event, ctx) => {
const clear = latestActiveClear(event.branchEntries);
if (!clear) return undefined;

const clearIndex = event.branchEntries.indexOf(clear);
const postClearEntries = event.branchEntries
.slice(clearIndex)
.map((entry, index) =>
index === 0 ? { ...entry, parentId: null } : entry,
);
const preparation = preparePostClearCompaction(
postClearEntries,
event.preparation.settings,
);
if (!preparation) {
return {
compaction: {
summary: "",
firstKeptEntryId: clear.id,
tokensBefore: event.preparation.tokensBefore,
details: { [CLEAR_COMPACTION_DETAIL]: true },
},
};
}

const model = ctx.model;
if (!model) return { cancel: true };
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
if (!auth.ok) throw new Error(auth.error);
const response = await generateSummaryWithUsage(
[...preparation.messagesToSummarize, ...preparation.turnPrefixMessages],
model,
preparation.settings.reserveTokens,
auth.apiKey,
auth.headers
? Object.fromEntries(
Object.entries(auth.headers).flatMap(([key, value]) =>
typeof value === "string" ? [[key, value]] : [],
),
)
: undefined,
event.signal,
event.customInstructions,
undefined,
ctx.thinkingLevel,
);
return {
compaction: {
summary: response.text,
firstKeptEntryId: preparation.firstKeptEntryId,
tokensBefore: preparation.tokensBefore,
usage: response.usage,
details: { [CLEAR_COMPACTION_DETAIL]: true },
Comment thread
ianwalter marked this conversation as resolved.
Comment thread
ianwalter marked this conversation as resolved.
},
};
});

pi.on("session_compact", (event) => {
const details = event.compactionEntry.details;
if (
!details ||
typeof details !== "object" ||
!(CLEAR_COMPACTION_DETAIL in details)
)
return;
pi.appendEntry(CLEAR_CONTEXT_MATERIALIZED_ENTRY);
});
}
76 changes: 53 additions & 23 deletions extensions/web-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type Theme,
} from "@earendil-works/pi-coding-agent";
import { agentEndTerminalNotice } from "../web/assistant-message.js";
import { isWebClearContextCommand } from "../web/clear-command.js";
import {
WEB_COMPACT_COMMAND,
WEB_COMPACT_EXTENSION_COMMAND,
Expand Down Expand Up @@ -52,6 +53,7 @@ import {
AUTO_ROUTER_COMPACTION_EVENT,
AUTO_ROUTER_MODEL_ROUTING_EVENT,
} from "./auto-router.js";
import { clearSessionContext } from "./clear-context.js";
import {
FOOTER_CONTRIBUTION_EVENT,
type FooterContribution,
Expand Down Expand Up @@ -140,11 +142,12 @@ export function isScopedModelAllowed(
function bridgeCommandList(pi: ExtensionAPI) {
const commands = pi
.getCommands()
.filter(
(command) =>
command.source === "prompt" ||
command.source === "skill" ||
command.name === "worktree",
.filter((command) =>
command.name === "clear"
? isWebClearContextCommand(command)
: command.source === "prompt" ||
command.source === "skill" ||
command.name === "worktree",
)
.map((command) => ({
name: command.name,
Expand Down Expand Up @@ -286,6 +289,8 @@ type BridgeState = {
autoTurnRouting: boolean;
/** True while Auto itself is applying a runtime model swap. */
autoRuntimeRouting: boolean;
/** A user Stop must settle as idle even when Pi reports abort as an error. */
abortRequested: boolean;
/** Latest browser model choice waiting for the active turn to settle. */
pendingModelSelection?: { provider: string; modelId: string };
applyingModelSelection?: boolean;
Expand Down Expand Up @@ -1012,6 +1017,10 @@ async function executeAgentCommand(
return;
}
case "abort": {
// Record the user intent before invoking Pi. Some providers/runtime
// paths surface cancellation as an error-shaped assistant message;
// the explicit Stop command is the authoritative classification.
state.abortRequested = true;
// Invoke the main abort before acknowledging, then let subagent teardown
// settle in the background. Compaction can delay that settlement well
// past the browser's command bound even though Stop has taken effect.
Expand Down Expand Up @@ -1083,6 +1092,14 @@ async function executeAgentCommand(
throw new Error("Wait for Pi to become idle before reloading");
pi.sendUserMessage(`/web-reload ${requestId}`);
return;
case "clear":
if (!pi.getCommands().some(isWebClearContextCommand))
throw new Error("Pi clear context support is unavailable");
if (!state.ctx.isIdle())
throw new Error("Wait for Pi to become idle before clearing context");
clearSessionContext(pi);
respond(state, requestId, true, { cleared: true });
return;
case "create_worktree":
case "create_worktree_v2": {
if (!state.ctx.isIdle())
Expand Down Expand Up @@ -1309,11 +1326,12 @@ async function connect(pi: ExtensionAPI, state: BridgeState): Promise<void> {
type: "agent.hello",
session: state.session,
historyMode: "replace",
// Send only active, compaction-aware history and bound its encoded size.
// The append-only JSONL can be hundreds of MB after old context is gone.
entries: boundedWebHistory(
state.ctx.sessionManager.buildContextEntries(),
),
// Send the raw active branch and bound its encoded size. The append-only
// JSONL can be hundreds of MB after old context is gone. Keep raw entries
// here: buildContextEntries() is already
// compaction-projected and would discard the pre-clear transcript before
// boundedWebHistory can recognize a clear-boundary compaction.
entries: boundedWebHistory(state.ctx.sessionManager.getBranch()),
// Forward the session's --models scope so the daemon's model picker
// shows the same list the TUI would.
scopedModels: state.ctx.scopedModels.map((item) => ({
Expand Down Expand Up @@ -1426,9 +1444,7 @@ export default function webSessions(pi: ExtensionAPI): void {
updateSession(bridge, {
model: selectedModel,
lastModel:
typeof value.restoreRoute === "string"
? value.restoreRoute
: null,
typeof value.restoreRoute === "string" ? value.restoreRoute : null,
});
}
}
Expand Down Expand Up @@ -1510,9 +1526,7 @@ export default function webSessions(pi: ExtensionAPI): void {
});
};

const activeBridgeFor = (
ctx: ExtensionContext,
): BridgeState | undefined => {
const activeBridgeFor = (ctx: ExtensionContext): BridgeState | undefined => {
const state = bridge;
return state &&
!state.closed &&
Expand Down Expand Up @@ -1785,6 +1799,7 @@ export default function webSessions(pi: ExtensionAPI): void {
pending: [],
autoTurnRouting: false,
autoRuntimeRouting: false,
abortRequested: false,
metrics: { usage: session.usage, contextUsage: session.contextUsage },
sourceReplacement,
};
Expand Down Expand Up @@ -1877,15 +1892,25 @@ export default function webSessions(pi: ExtensionAPI): void {
});
pi.on("thinking_level_select", (event, ctx) => {
const activeBridge = activeBridgeFor(ctx);
if (activeBridge) updateSession(activeBridge, { thinkingLevel: event.level });
if (activeBridge)
updateSession(activeBridge, { thinkingLevel: event.level });
forward(event, ctx);
});
pi.on("agent_start", (event, ctx) => forward(event, ctx, "working"));
// The visible run is complete at agent_end. Surface provider/runtime failures
// instead of making an unfinished transcript look successfully idle.
pi.on("agent_start", (event, ctx) => {
const activeBridge = activeBridgeFor(ctx);
if (activeBridge) activeBridge.abortRequested = false;
forward(event, ctx, "working");
});
// The visible run is complete at agent_end. An explicit Stop is authoritative:
// Pi may encode cancellation as stopReason "error", but user cancellation is
// not a failed session and must settle the sidebar back to idle.
pi.on("agent_end", (event, ctx) => {
const activeBridge = activeBridgeFor(ctx);
const status =
agentEndTerminalNotice(event)?.kind === "error" ? "error" : "idle";
activeBridge?.abortRequested ||
agentEndTerminalNotice(event)?.kind !== "error"
? "idle"
: "error";
forward(event, ctx, status);
});
pi.on("agent_settled", async (event, ctx) => {
Expand All @@ -1910,8 +1935,11 @@ export default function webSessions(pi: ExtensionAPI): void {
forward(
event,
ctx,
activeBridge?.session.status === "error" ? "error" : "idle",
activeBridge?.abortRequested || activeBridge?.session.status !== "error"
? "idle"
: "error",
);
if (activeBridge) activeBridge.abortRequested = false;
// Settlement must remain observable even if provider credential resolution
// for the deferred model is slow. The server-side pending-model gate keeps
// queued prompts blocked until the following model update succeeds.
Expand Down Expand Up @@ -1954,7 +1982,9 @@ export default function webSessions(pi: ExtensionAPI): void {
send(bridge, {
type: "agent.history",
sessionId: bridge.session.id,
entries: boundedWebHistory(ctx.sessionManager.buildContextEntries()),
// Send raw active-branch entries so clear-boundary compactions retain
// the transcript before the boundary in the web projection.
entries: boundedWebHistory(ctx.sessionManager.getBranch()),
} satisfies AgentHistoryMessage);
endBridgeCompaction(bridge, {
aborted: false,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
},
"pi": {
"extensions": [
"./extensions/clear-context.ts",
"./extensions/model-order.ts",
"./extensions/session-footer.ts",
"./extensions/pr-footer.ts",
Expand Down
Loading