348 lines
11 KiB
TypeScript
348 lines
11 KiB
TypeScript
/**
|
|
* Main extension entrypoint.
|
|
*
|
|
* Wires together request patching, remote compaction, runtime state
|
|
* reconstruction, session lifecycle cleanup, and Pi-native HTTP request patching.
|
|
*/
|
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
import { isRecord, loadConfig } from "./config.ts";
|
|
import {
|
|
applyRemoteHistoryPayloadPatch,
|
|
extractResponsesReasoningConfig,
|
|
extractResponsesTextConfig,
|
|
isOpenAICodexResponsesModel,
|
|
looksLikeResponsesPayload,
|
|
messageMatchesModel,
|
|
modelKey,
|
|
supportsRemoteCompactionModel,
|
|
thinkingLevelToResponsesReasoning,
|
|
} from "./openai.ts";
|
|
import {
|
|
buildCompactionSummaryText,
|
|
buildRemoteCompactionDetails,
|
|
buildToolsPayload,
|
|
callRemoteCompactionEndpoint,
|
|
generateBestEffortLocalSummary,
|
|
messageToResponseItems,
|
|
messagesToResponseItems,
|
|
normalizeResponseItemsForPrompt,
|
|
reconstructRemoteCompactionStateFromBranch,
|
|
} from "./remote-compaction.ts";
|
|
import {
|
|
clearAllRuntimeState,
|
|
clearRemoteCompactionState,
|
|
clearResponsesRequestShapeState,
|
|
getRemoteCompactionState,
|
|
getResponsesRequestShapeState,
|
|
setRemoteCompactionState,
|
|
setResponsesRequestShapeState,
|
|
} from "./state.ts";
|
|
|
|
type TargetModel = Parameters<typeof modelKey>[0];
|
|
|
|
type BranchEntry = {
|
|
type: string;
|
|
id: string;
|
|
details?: unknown;
|
|
message?: unknown;
|
|
thinkingLevel?: unknown;
|
|
};
|
|
|
|
type SessionContextLike = {
|
|
sessionManager: {
|
|
getSessionId(): string;
|
|
getBranch(): BranchEntry[];
|
|
};
|
|
};
|
|
|
|
function getSessionId(ctx: SessionContextLike): string {
|
|
return ctx.sessionManager.getSessionId();
|
|
}
|
|
|
|
function getBranchMessages(branchEntries: BranchEntry[]): AgentMessage[] {
|
|
return branchEntries.flatMap((entry) =>
|
|
entry.type === "message" && entry.message ? [entry.message as AgentMessage] : [],
|
|
);
|
|
}
|
|
|
|
function getBranchThinkingLevel(branchEntries: BranchEntry[]): string | undefined {
|
|
for (let index = branchEntries.length - 1; index >= 0; index--) {
|
|
const entry = branchEntries[index];
|
|
if (entry?.type !== "thinking_level_change") continue;
|
|
return typeof entry.thinkingLevel === "string" ? entry.thinkingLevel : undefined;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function clearSessionRuntimeState(sessionId: string | undefined): void {
|
|
clearRemoteCompactionState(sessionId);
|
|
clearResponsesRequestShapeState(sessionId);
|
|
}
|
|
|
|
function syncRemoteState(ctx: SessionContextLike): void {
|
|
const sessionId = getSessionId(ctx);
|
|
const branchEntries = ctx.sessionManager.getBranch() as Array<{
|
|
type: string;
|
|
id: string;
|
|
details?: unknown;
|
|
message?: AgentMessage;
|
|
}>;
|
|
const state = reconstructRemoteCompactionStateFromBranch({ branchEntries });
|
|
if (state) {
|
|
setRemoteCompactionState(sessionId, state);
|
|
} else {
|
|
clearRemoteCompactionState(sessionId);
|
|
}
|
|
}
|
|
|
|
function getMatchingRemoteState(
|
|
sessionId: string,
|
|
model: TargetModel | undefined,
|
|
): ReturnType<typeof getRemoteCompactionState> {
|
|
if (!model) return undefined;
|
|
const remoteState = getRemoteCompactionState(sessionId);
|
|
return remoteState && remoteState.modelKey === modelKey(model) ? remoteState : undefined;
|
|
}
|
|
|
|
function extendRemoteHistoryIfCompatible(params: {
|
|
sessionId: string;
|
|
model: TargetModel | undefined;
|
|
message: AgentMessage;
|
|
}): void {
|
|
const remoteState = getMatchingRemoteState(params.sessionId, params.model);
|
|
if (!remoteState || !params.model) return;
|
|
if (params.message.role === "assistant" && !messageMatchesModel(params.message, params.model)) {
|
|
return;
|
|
}
|
|
|
|
const items = messageToResponseItems(params.message);
|
|
if (items.length === 0) return;
|
|
|
|
setRemoteCompactionState(params.sessionId, {
|
|
...remoteState,
|
|
explicitHistory: [...remoteState.explicitHistory, ...items],
|
|
});
|
|
}
|
|
|
|
function maybeNotifyRequestFeatures(params: {
|
|
notifiedModels: Set<string>;
|
|
hasUI: boolean;
|
|
notify: boolean;
|
|
ui: { notify(message: string, level: "info" | "warning"): void };
|
|
model: TargetModel;
|
|
features: string[];
|
|
}): void {
|
|
if (!params.notify || !params.hasUI || params.features.length === 0) return;
|
|
|
|
const key = `${String(params.model.provider)}/${String(params.model.id)}`;
|
|
const noticeKey = `${key}:${params.features.join(",")}`;
|
|
if (params.notifiedModels.has(noticeKey)) return;
|
|
|
|
params.notifiedModels.add(noticeKey);
|
|
params.ui.notify(`OpenAI compaction active for ${key} (${params.features.join(", ")})`, "info");
|
|
}
|
|
|
|
export default function openaiServerCompactionExtension(pi: ExtensionAPI) {
|
|
const notifiedModels = new Set<string>();
|
|
|
|
pi.on("session_start", (_event, ctx) => {
|
|
const sessionId = getSessionId(ctx);
|
|
clearResponsesRequestShapeState(sessionId);
|
|
syncRemoteState(ctx);
|
|
});
|
|
|
|
const clearBeforeSessionChange = (_event: unknown, ctx: SessionContextLike): void => {
|
|
clearSessionRuntimeState(getSessionId(ctx));
|
|
};
|
|
pi.on("session_before_switch", clearBeforeSessionChange);
|
|
pi.on("session_before_fork", clearBeforeSessionChange);
|
|
pi.on("session_before_tree", clearBeforeSessionChange);
|
|
|
|
const syncAfterSessionChange = (_event: unknown, ctx: SessionContextLike): void => {
|
|
syncRemoteState(ctx);
|
|
};
|
|
pi.on("session_tree", syncAfterSessionChange);
|
|
pi.on("session_compact", syncAfterSessionChange);
|
|
|
|
pi.on("model_select", (_event, ctx) => {
|
|
clearResponsesRequestShapeState(getSessionId(ctx));
|
|
});
|
|
|
|
pi.on("session_shutdown", () => {
|
|
clearAllRuntimeState();
|
|
});
|
|
|
|
pi.on("session_before_compact", async (event, ctx) => {
|
|
const cfg = loadConfig(ctx.cwd);
|
|
const model = ctx.model;
|
|
if (!cfg.enabled || !model || !supportsRemoteCompactionModel(model)) return undefined;
|
|
|
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
if (!auth.ok || !auth.apiKey) return undefined;
|
|
|
|
const tools = buildToolsPayload(pi.getAllTools(), pi.getActiveTools());
|
|
const sessionId = getSessionId(ctx);
|
|
const branchEntries = event.branchEntries as BranchEntry[];
|
|
const remoteState = getMatchingRemoteState(sessionId, model);
|
|
const observedRequestShape = getResponsesRequestShapeState(sessionId);
|
|
const fullBranchMessages = getBranchMessages(branchEntries);
|
|
const responseItems = remoteState
|
|
? remoteState.explicitHistory
|
|
: messagesToResponseItems(fullBranchMessages);
|
|
const promptResponseItems = normalizeResponseItemsForPrompt(responseItems, model);
|
|
const thinkingLevel = pi.getThinkingLevel();
|
|
const fallbackReasoning = model.reasoning
|
|
? thinkingLevelToResponsesReasoning(thinkingLevel ?? getBranchThinkingLevel(branchEntries))
|
|
: undefined;
|
|
const reasoning = observedRequestShape?.reasoning ?? fallbackReasoning;
|
|
const text = observedRequestShape?.text;
|
|
|
|
const [localResult, remoteResult] = await Promise.allSettled([
|
|
generateBestEffortLocalSummary({
|
|
preparation: event.preparation,
|
|
messages: fullBranchMessages,
|
|
model,
|
|
apiKey: auth.apiKey,
|
|
headers: auth.headers,
|
|
customInstructions: event.customInstructions,
|
|
signal: event.signal,
|
|
thinkingLevel,
|
|
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
tokensBefore: event.preparation.tokensBefore,
|
|
}),
|
|
callRemoteCompactionEndpoint({
|
|
model,
|
|
apiKey: auth.apiKey,
|
|
headers: auth.headers,
|
|
sessionId,
|
|
input: promptResponseItems,
|
|
instructions: ctx.getSystemPrompt(),
|
|
tools,
|
|
parallelToolCalls: true,
|
|
reasoning,
|
|
text,
|
|
signal: event.signal,
|
|
}),
|
|
]);
|
|
|
|
if (remoteResult.status !== "fulfilled") {
|
|
if (localResult.status === "fulfilled") {
|
|
return { compaction: localResult.value };
|
|
}
|
|
if (!event.signal.aborted && ctx.hasUI) {
|
|
const message =
|
|
remoteResult.reason instanceof Error
|
|
? remoteResult.reason.message
|
|
: String(remoteResult.reason);
|
|
ctx.ui.notify(
|
|
`OpenAI remote compaction failed; falling back to default compaction. ${message}`,
|
|
"warning",
|
|
);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
const remoteDetails = buildRemoteCompactionDetails(
|
|
model,
|
|
remoteResult.value.output,
|
|
remoteResult.value.usage,
|
|
);
|
|
const localSummary =
|
|
localResult.status === "fulfilled"
|
|
? localResult.value
|
|
: {
|
|
summary: buildCompactionSummaryText(model),
|
|
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
tokensBefore: event.preparation.tokensBefore,
|
|
};
|
|
|
|
return {
|
|
compaction: {
|
|
summary: localSummary.summary,
|
|
firstKeptEntryId: localSummary.firstKeptEntryId,
|
|
tokensBefore: localSummary.tokensBefore,
|
|
details: {
|
|
...(localSummary.details !== undefined
|
|
? { localSummaryDetails: localSummary.details }
|
|
: {}),
|
|
remoteCompaction: remoteDetails,
|
|
},
|
|
},
|
|
};
|
|
});
|
|
|
|
pi.on("message_end", (event, ctx) => {
|
|
const sessionId = getSessionId(ctx);
|
|
const model = ctx.model;
|
|
|
|
extendRemoteHistoryIfCompatible({
|
|
sessionId,
|
|
model,
|
|
message: event.message,
|
|
});
|
|
});
|
|
|
|
pi.on("before_provider_request", (event, ctx) => {
|
|
const cfg = loadConfig(ctx.cwd);
|
|
if (!cfg.enabled) return undefined;
|
|
|
|
const model = ctx.model;
|
|
if (
|
|
!model ||
|
|
!supportsRemoteCompactionModel(model) ||
|
|
!isRecord(event.payload) ||
|
|
!looksLikeResponsesPayload(event.payload)
|
|
) {
|
|
return undefined;
|
|
}
|
|
|
|
const sessionId = getSessionId(ctx);
|
|
setResponsesRequestShapeState(sessionId, {
|
|
updatedAt: Date.now(),
|
|
reasoning: extractResponsesReasoningConfig(event.payload),
|
|
text: extractResponsesTextConfig(event.payload),
|
|
});
|
|
const remoteState = getMatchingRemoteState(sessionId, model);
|
|
|
|
if (isOpenAICodexResponsesModel(model)) {
|
|
if (!remoteState) return undefined;
|
|
const payload = applyRemoteHistoryPayloadPatch({
|
|
payload: event.payload,
|
|
explicitHistory: normalizeResponseItemsForPrompt(
|
|
remoteState.explicitHistory,
|
|
model,
|
|
) as unknown[],
|
|
});
|
|
maybeNotifyRequestFeatures({
|
|
notifiedModels,
|
|
hasUI: ctx.hasUI,
|
|
notify: cfg.notify,
|
|
ui: ctx.ui,
|
|
model,
|
|
features: ["remote_compaction_history"],
|
|
});
|
|
return payload;
|
|
}
|
|
|
|
if (!remoteState) return undefined;
|
|
|
|
const payload = applyRemoteHistoryPayloadPatch({
|
|
payload: event.payload,
|
|
explicitHistory: normalizeResponseItemsForPrompt(
|
|
remoteState.explicitHistory,
|
|
model,
|
|
) as unknown[],
|
|
});
|
|
maybeNotifyRequestFeatures({
|
|
notifiedModels,
|
|
hasUI: ctx.hasUI,
|
|
notify: cfg.notify,
|
|
ui: ctx.ui,
|
|
model,
|
|
features: ["remote_compaction_history", "native_http"],
|
|
});
|
|
|
|
return payload;
|
|
});
|
|
}
|