feat: unify context compaction

This commit is contained in:
云服务部-叶林立
2026-09-01 14:52:43 +08:00
parent 44f27f7585
commit a4807dde67
114 changed files with 2681 additions and 21336 deletions
+11
View File
@@ -0,0 +1,11 @@
# Changelog
## 1.0.0
- Introduce one incremental Context Authority for provider requests, threshold decisions, UI metrics, and native compaction.
- Add deterministic tool-result compaction with source-backed recovery.
- Add a bounded asynchronous semantic-summary worker.
- Add Codex remote compaction using the effective projection rather than the raw Session branch.
- Add portable checkpoint text for provider/model switching.
- Add `context_tree_query` and `/compaction-status`.
- Deliberately omit legacy pi-condense and Codex checkpoint/config compatibility.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Can Celik
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2025 championswimmer (original pi-context-prune)
Copyright (c) 2026 Jacek Juraszek (pi-condense)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+63
View File
@@ -0,0 +1,63 @@
# pi-compaction
High-performance unified context projection and compaction for Pi.
## Responsibilities
- incrementally index completed tool calls;
- replace large tool results with deterministic bounded evidence stubs;
- refine large stubs with non-blocking semantic summaries;
- expose one effective context projection to normal provider requests and Codex native compaction;
- cancel Pi's raw threshold compaction until the effective lower-bound reaches the configured threshold;
- retain original tool evidence through `context_tree_query`;
- publish read-only effective metrics for the footer and context observer.
Context Mode remains responsible for preventing large external data from entering the session. RTK remains responsible for ordinary tool-output cleanup.
## Configuration
Set `piCompaction` in the Pi agent `settings.json`:
```json
{
"piCompaction": {
"enabled": true,
"softRatio": 0.72,
"hardRatio": 0.9,
"targetRatio": 0.55,
"deterministic": {
"enabled": true,
"maxStubTokens": 256
},
"semantic": {
"enabled": true,
"model": "default",
"thinking": "low",
"concurrency": 1,
"minInputChars": 4000,
"maxInputChars": 24000,
"maxRetries": 2
},
"native": {
"enabled": true
}
}
}
```
The extension never writes settings. Invalid or missing fields are normalized in memory.
## Session format
The only custom entry type is `pi-compaction`, version 1. Segment summary entries contain hashes, summaries, and source references; they never duplicate complete raw tool output. Native checkpoints use Pi compaction-entry details with the same kind/version.
This implementation intentionally does not read legacy `context-prune-*` or `openai-codex-native-compaction` entries. Start a new session after switching to this extension.
## Interfaces
- `context_tree_query`: recover original bounded tool evidence by source key or tool call ID.
- `/compaction-status`: show raw/effective tokens, reclaimed tokens, confidence, segments, and pending semantic jobs.
## Performance model
The first session hydration scans the branch once. Appended turns use delta projection and immutable cached snapshots. The `context` hook performs no model call and no session write. Semantic summaries run in a bounded background worker and become visible only at later safe event boundaries.
+23
View File
@@ -0,0 +1,23 @@
# Upstream sources
This package is maintained directly in `my-pi` and combines two separately licensed sources with a new local Context Authority.
## pi-condense reference
- Official repository: https://github.com/jjuraszek/pi-condense
- Snapshot: `59c95110aacd8131db71e21296765dd80b72fae8`
- Tag/version: `v2.9.1`
- Used as a behavioral and summarizer-reliability reference. The new segment/index/runtime implementation is local and intentionally does not retain legacy formats.
- License: `LICENSES/pi-condense-MIT.txt`
## Pi Codex compaction source
- Official repository: https://github.com/ogulcancelik/pi-extensions
- Package path: `packages/pi-codex-compaction/`
- Snapshot: `c4d6ca8fb484ba51684296ba8b4715f8c7b3ac91`
- Version at snapshot: `0.1.4`
- Imported source: remote compaction protocol, Response API conversion, retention helpers, retry parsing, and request sanitization in `src/native-compaction.ts`.
- Local changes: unified checkpoint kind/version, removed raw-branch effective-input construction and legacy checkpoint parsing, exported the shared message projection, made retained-user budget configurable, and renamed the client user agent.
- License: `LICENSES/pi-codex-compaction-MIT.txt`
No npm tarball, npm cache, `node_modules`, build output, or nested Git metadata is used as an import source.
+590
View File
@@ -0,0 +1,590 @@
import { createHash, randomUUID } from "node:crypto";
import { Type } from "typebox";
import {
buildSessionContext,
convertToLlm,
estimateTokens,
generateSummaryWithUsage,
sessionEntryToContextMessages,
truncateHead,
DEFAULT_MAX_BYTES,
DEFAULT_MAX_LINES,
type ExtensionAPI,
type ExtensionContext,
type SessionEntry,
} from "@earendil-works/pi-coding-agent";
import { DEFAULT_CONFIG, loadConfig } from "./src/config.js";
import {
buildCodexHeaders,
buildCompactionRequestBody,
buildReplacementHistory,
buildToolPayload,
callRemoteCompaction,
findNativeCheckpoint,
isJsonObject,
isOpenAICodexModel,
mergeFeatureHeader,
messagesToResponseItems,
modelKey,
NATIVE_COMPACTION_KIND,
NATIVE_COMPACTION_VERSION,
resolveCodexResponsesUrl,
stripInputFromPayload,
type JsonObject,
type NativeCompactionDetails,
type ResponseItem,
} from "./src/native-compaction.js";
import { SegmentIndex } from "./src/segment-index.js";
import { SemanticWorker } from "./src/semantic-worker.js";
import { shouldCompact } from "./src/token-accounting.js";
import {
CompactionServiceRegistry,
publishCompactionService,
unpublishCompactionService,
} from "./src/service.js";
import {
COMPACTION_CUSTOM_TYPE,
COMPACTION_VERSION,
QUERY_TOOL_NAME,
type PiCompactionConfig,
type ProjectionSnapshot,
type ToolHistoryRecord,
} from "./src/types.js";
interface SessionRuntime {
index: SegmentIndex;
config: PiCompactionConfig;
codexCache?: { fingerprint: string; modelKey: string; toolsHash: string; items: ResponseItem[] };
}
interface CachedPayload {
modelKey: string;
payload: JsonObject;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function hash(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 20);
}
interface EnvironmentValue {
hash: string;
tokens: number;
contextWindow: number;
}
interface EnvironmentCacheEntry extends EnvironmentValue {
prompt: string;
modelIdentity: string;
activeNames: string[];
tools: unknown[];
}
function environment(
pi: ExtensionAPI,
ctx: ExtensionContext,
cache: Map<string, EnvironmentCacheEntry>,
): EnvironmentValue {
const sessionId = ctx.sessionManager.getSessionId();
const prompt = ctx.getSystemPrompt();
const activeNames = pi.getActiveTools();
const active = new Set(activeNames);
const tools = pi.getAllTools().filter((tool) => active.has(tool.name));
const contextWindow = ctx.model?.contextWindow ?? 0;
const modelIdentity = ctx.model ? modelKey(ctx.model) : "none";
const previous = cache.get(sessionId);
if (
previous
&& previous.prompt === prompt
&& previous.contextWindow === contextWindow
&& previous.modelIdentity === modelIdentity
&& previous.activeNames.length === activeNames.length
&& previous.activeNames.every((name, index) => name === activeNames[index])
&& previous.tools.length === tools.length
&& previous.tools.every((tool, index) => tool === tools[index])
) return previous;
const serializedTools = JSON.stringify(tools.map((tool) => ({
name: tool.name,
description: tool.description,
parameters: tool.parameters,
})));
const text = `${modelIdentity}\n${prompt}\n${serializedTools}`;
const value: EnvironmentCacheEntry = {
prompt,
modelIdentity,
activeNames: [...activeNames],
tools,
hash: hash(text),
tokens: Math.ceil(text.length / 4),
contextWindow,
};
cache.set(sessionId, value);
return value;
}
function portableSummary(messages: readonly any[], maxChars = 24_000): string {
const sections: string[] = ["Portable context checkpoint generated by pi-compaction."];
for (const message of messages) {
if (message?.role === "toolResult") {
const text = Array.isArray(message.content)
? message.content.flatMap((part: any) => part?.type === "text" ? [part.text] : []).join("\n")
: "";
if (text) sections.push(`[Tool ${message.toolName ?? "unknown"}] ${text}`);
continue;
}
if (message?.role === "user") {
const text = typeof message.content === "string"
? message.content
: Array.isArray(message.content)
? message.content.flatMap((part: any) => part?.type === "text" ? [part.text] : []).join("\n")
: "";
if (text) sections.push(`[User] ${text}`);
continue;
}
if (message?.role === "assistant" && Array.isArray(message.content)) {
const text = message.content.flatMap((part: any) => part?.type === "text" ? [part.text] : []).join("\n");
if (text) sections.push(`[Assistant] ${text}`);
}
}
const combined = sections.join("\n\n");
if (combined.length <= maxChars) return combined;
const head = Math.floor(maxChars * 0.35);
const tail = maxChars - head - 32;
return `${combined.slice(0, head)}\n\n… checkpoint truncated …\n\n${combined.slice(-tail)}`;
}
function asPortableMessage(summary: string): any {
return {
role: "user",
content: [{ type: "text", text: summary }],
timestamp: Date.now(),
};
}
function setFeatureHeader(headers: Record<string, string | null>): void {
const existing = Object.entries(headers).find(([name]) => name.toLowerCase() === "x-codex-beta-features");
if (existing) headers[existing[0]] = mergeFeatureHeader(existing[1]);
else headers["x-codex-beta-features"] = mergeFeatureHeader(undefined);
}
function cleanHeaders(headers: Record<string, string | null> | undefined): Record<string, string> | undefined {
if (!headers) return undefined;
return Object.fromEntries(Object.entries(headers).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
}
function activeToolsHash(pi: ExtensionAPI): string {
return hash(pi.getActiveTools().join("\0"));
}
export default function piCompactionExtension(pi: ExtensionAPI): void {
const runtimes = new Map<string, SessionRuntime>();
const payloads = new Map<string, CachedPayload>();
const environments = new Map<string, EnvironmentCacheEntry>();
const worker = new SemanticWorker();
const service = new CompactionServiceRegistry();
publishCompactionService(service);
const runtimeFor = (ctx: ExtensionContext): SessionRuntime => {
const sessionId = ctx.sessionManager.getSessionId();
let runtime = runtimes.get(sessionId);
if (!runtime) {
const index = new SegmentIndex(sessionId);
index.hydrate(ctx.sessionManager.getBranch() as SessionEntry[], DEFAULT_CONFIG);
runtime = { index, config: structuredClone(DEFAULT_CONFIG) };
runtimes.set(sessionId, runtime);
service.setSession(sessionId, (ids) => index.query(ids));
}
return runtime;
};
const persist = (record: unknown): void => {
pi.appendEntry(COMPACTION_CUSTOM_TYPE, record);
};
const drainSemantic = (ctx: ExtensionContext, runtime: SessionRuntime): void => {
const sessionId = ctx.sessionManager.getSessionId();
for (const result of worker.drain(sessionId)) {
const record = runtime.index.applySemantic(result.sourceKey, result.sourceHash, result.summary);
if (record) persist(record);
}
runtime.index.setSemanticJobs(worker.count(sessionId));
};
const projectMessages = (
ctx: ExtensionContext,
runtime: SessionRuntime,
messages: readonly any[],
): ProjectionSnapshot => {
const env = environment(pi, ctx, environments);
runtime.index.setSemanticJobs(worker.count(ctx.sessionManager.getSessionId()));
const snapshot = runtime.index.project(messages, {
environmentHash: env.hash,
environmentTokens: env.tokens,
contextWindow: env.contextWindow,
});
service.setSnapshot(snapshot);
return snapshot;
};
const branchMessages = (
ctx: ExtensionContext,
runtime: SessionRuntime,
branch: SessionEntry[],
excludeLastAssistantError = false,
): { snapshot: ProjectionSnapshot; checkpoint: ReturnType<typeof findNativeCheckpoint> } => {
const checkpoint = findNativeCheckpoint(branch);
let messages: any[];
if (checkpoint.status === "valid") {
messages = branch
.slice(checkpoint.checkpoint.entryIndex + 1)
.flatMap((entry) => sessionEntryToContextMessages(entry));
if (checkpoint.checkpoint.details.modelKey !== (ctx.model ? modelKey(ctx.model) : "")) {
messages = [asPortableMessage(checkpoint.checkpoint.details.portableSummary), ...messages];
}
} else {
messages = buildSessionContext(branch).messages;
}
if (excludeLastAssistantError) {
const index = messages.findLastIndex((message) => message.role === "assistant");
if (index >= 0 && messages[index]?.stopReason === "error") messages = messages.toSpliced(index, 1);
}
return { snapshot: projectMessages(ctx, runtime, messages), checkpoint };
};
const codexInput = (
piModel: any,
runtime: SessionRuntime,
snapshot: ProjectionSnapshot,
checkpoint?: NativeCompactionDetails,
): ResponseItem[] => {
const toolsHash = activeToolsHash(pi);
const key = modelKey(piModel);
const cached = runtime.codexCache;
if (cached && cached.fingerprint === snapshot.fingerprint && cached.modelKey === key && cached.toolsHash === toolsHash) {
return cached.items;
}
const tail = messagesToResponseItems(piModel, convertToLlm(snapshot.messages as any), pi.getAllTools());
const items = checkpoint && checkpoint.modelKey === key
? [...structuredClone(checkpoint.replacementHistory), ...tail]
: tail;
runtime.codexCache = { fingerprint: snapshot.fingerprint, modelKey: key, toolsHash, items };
snapshot.codexItems = items;
return items;
};
pi.on("session_start", async (_event, ctx) => {
publishCompactionService(service);
const sessionId = ctx.sessionManager.getSessionId();
const config = await loadConfig();
const index = new SegmentIndex(sessionId);
index.hydrate(ctx.sessionManager.getBranch() as SessionEntry[], config);
runtimes.set(sessionId, { index, config });
service.setSession(sessionId, (ids) => index.query(ids));
});
pi.on("session_shutdown", (_event, ctx) => {
const sessionId = ctx.sessionManager.getSessionId();
worker.cancelSession(sessionId);
runtimes.delete(sessionId);
payloads.delete(sessionId);
environments.delete(sessionId);
service.deleteSession(sessionId);
if (runtimes.size === 0) unpublishCompactionService(service);
});
pi.on("session_tree", (_event, ctx) => {
const runtime = runtimeFor(ctx);
runtime.index.hydrate(ctx.sessionManager.getBranch() as SessionEntry[], runtime.config);
runtime.codexCache = undefined;
service.clearSnapshot(ctx.sessionManager.getSessionId());
});
pi.on("session_compact", (_event, ctx) => {
const runtime = runtimeFor(ctx);
runtime.index.hydrate(ctx.sessionManager.getBranch() as SessionEntry[], runtime.config);
runtime.codexCache = undefined;
service.clearSnapshot(ctx.sessionManager.getSessionId());
payloads.delete(ctx.sessionManager.getSessionId());
});
pi.on("model_select", (_event, ctx) => {
const sessionId = ctx.sessionManager.getSessionId();
payloads.delete(sessionId);
environments.delete(sessionId);
const runtime = runtimes.get(sessionId);
if (runtime) runtime.codexCache = undefined;
});
pi.on("turn_end", (event, ctx) => {
const runtime = runtimeFor(ctx);
drainSemantic(ctx, runtime);
const previous = runtime.index.getSnapshot();
const captured = runtime.index.captureTurn(event.message, event.toolResults, runtime.config);
for (const record of captured) persist(runtime.index.toPersisted(record));
const pressure = previous && previous.contextWindow > 0
? previous.upperBound / previous.contextWindow >= runtime.config.softRatio
: false;
const semanticCandidates = pressure
? runtime.index.allRecords()
: captured.filter((record) => record.rawText.length >= runtime.config.semantic.maxInputChars);
for (const record of semanticCandidates) {
worker.enqueue(ctx.sessionManager.getSessionId(), record, ctx, runtime.config);
}
runtime.index.setSemanticJobs(worker.count(ctx.sessionManager.getSessionId()));
const usage = ctx.getContextUsage();
if (usage?.tokens && previous) {
const env = environment(pi, ctx, environments);
runtime.index.recordUsage(
usage.tokens,
previous.messageEstimate + estimateTokens(event.message as any),
env.hash,
);
}
});
pi.on("context", (event, ctx) => {
const runtime = runtimeFor(ctx);
const branch = ctx.sessionManager.getBranch() as SessionEntry[];
const checkpoint = findNativeCheckpoint(branch);
if (checkpoint.status === "invalid") {
ctx.abort();
if (ctx.hasUI) ctx.ui.notify("pi-compaction checkpoint is malformed; request blocked.", "error");
return { messages: [] };
}
if (checkpoint.status === "valid") {
const projected = branchMessages(ctx, runtime, branch);
return { messages: projected.snapshot.messages as any };
}
return { messages: projectMessages(ctx, runtime, event.messages).messages as any };
});
pi.on("before_provider_headers", (event, ctx) => {
if (isOpenAICodexModel(ctx.model)) setFeatureHeader(event.headers);
});
pi.on("before_provider_request", (event, ctx) => {
const model = ctx.model;
if (!model || !isOpenAICodexModel(model) || !isJsonObject(event.payload)) return undefined;
const runtime = runtimeFor(ctx);
const sessionId = ctx.sessionManager.getSessionId();
payloads.set(sessionId, { modelKey: modelKey(model), payload: stripInputFromPayload(event.payload) });
const checkpoint = findNativeCheckpoint(ctx.sessionManager.getBranch() as SessionEntry[]);
if (checkpoint.status !== "valid" || checkpoint.checkpoint.details.modelKey !== modelKey(model)) return undefined;
const snapshot = runtime.index.getSnapshot();
if (!snapshot) return undefined;
const payload: JsonObject = {
...event.payload,
input: codexInput(model, runtime, snapshot, checkpoint.checkpoint.details),
};
delete payload.messages;
delete payload.previous_response_id;
return payload;
});
pi.on("session_before_compact", async (event, ctx) => {
const runtime = runtimeFor(ctx);
if (!runtime.config.enabled) return undefined;
drainSemantic(ctx, runtime);
const { snapshot, checkpoint } = branchMessages(
ctx,
runtime,
event.branchEntries as SessionEntry[],
event.reason === "overflow" && event.willRetry,
);
if (!shouldCompact(event.reason, snapshot, runtime.config)) return { cancel: true };
const model = ctx.model;
if (!model) return undefined;
if (!runtime.config.native.enabled || !isOpenAICodexModel(model)) {
try {
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
if (!auth.ok) return undefined;
const result = await generateSummaryWithUsage(
snapshot.messages as any,
model,
16_384,
auth.apiKey,
cleanHeaders(auth.headers),
event.signal,
event.customInstructions,
undefined,
runtime.config.semantic.thinking,
);
return {
compaction: {
summary: result.text,
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: snapshot.effectiveTokens,
usage: result.usage,
details: {
kind: COMPACTION_CUSTOM_TYPE,
version: COMPACTION_VERSION,
checkpoint: "portable",
sourceFingerprint: snapshot.fingerprint,
},
},
};
} catch (error) {
if (!event.signal.aborted) console.error("pi-compaction: portable checkpoint failed:", error);
return undefined;
}
}
try {
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
if (!auth.ok || !auth.apiKey) throw new Error(auth.ok ? "Codex authentication unavailable" : auth.error);
const currentCheckpoint = checkpoint.status === "valid" ? checkpoint.checkpoint.details : undefined;
const input = codexInput(model, runtime, snapshot, currentCheckpoint);
const sessionId = ctx.sessionManager.getSessionId();
const cached = payloads.get(sessionId);
const body = buildCompactionRequestBody({
basePayload: cached?.modelKey === modelKey(model) ? cached.payload : undefined,
model,
input,
instructions: ctx.getSystemPrompt(),
tools: buildToolPayload(pi.getAllTools(), pi.getActiveTools()),
sessionId,
});
if (ctx.hasUI) ctx.ui.notify("pi-compaction: creating Codex native checkpoint…", "info");
const remote = await callRemoteCompaction({
url: resolveCodexResponsesUrl(model.baseUrl),
headers: buildCodexHeaders({ apiKey: auth.apiKey, headers: cleanHeaders(auth.headers), sessionId }),
body,
signal: event.signal,
});
const details: NativeCompactionDetails = {
kind: NATIVE_COMPACTION_KIND,
version: NATIVE_COMPACTION_VERSION,
checkpoint: "native",
modelKey: modelKey(model),
replacementHistory: buildReplacementHistory(
input,
remote.compactionItem,
Math.min(64_000, Math.max(4_096, Math.floor(snapshot.contextWindow * Math.max(0.05, runtime.config.targetRatio - 0.3)))),
),
portableSummary: portableSummary(snapshot.messages),
sourceFingerprint: snapshot.fingerprint,
};
return {
compaction: {
summary: `Pi native checkpoint ${randomUUID()}`,
firstKeptEntryId: event.preparation.firstKeptEntryId,
tokensBefore: snapshot.effectiveTokens,
usage: remote.usage,
details,
},
};
} catch (error) {
if (!event.signal.aborted && ctx.hasUI) {
ctx.ui.notify(`pi-compaction native checkpoint failed; falling back to Pi: ${errorMessage(error)}`, "warning");
}
return undefined;
}
});
pi.on("session_before_tree", async (event, ctx) => {
const model = ctx.model;
if (!event.preparation.userWantsSummary || !model) return undefined;
const runtime = runtimeFor(ctx);
if (!runtime.config.enabled) return undefined;
const messages = event.preparation.entriesToSummarize.flatMap((entry) => sessionEntryToContextMessages(entry));
const snapshot = projectMessages(ctx, runtime, messages);
try {
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
if (!auth.ok) return undefined;
const result = await generateSummaryWithUsage(
snapshot.messages as any,
model,
16_384,
auth.apiKey,
cleanHeaders(auth.headers),
event.signal,
event.preparation.customInstructions,
undefined,
runtime.config.semantic.thinking,
undefined,
undefined,
undefined,
undefined,
);
return {
summary: {
summary: result.text,
usage: result.usage,
details: {
kind: COMPACTION_CUSTOM_TYPE,
version: COMPACTION_VERSION,
checkpoint: "portable",
sourceFingerprint: snapshot.fingerprint,
},
},
};
} catch (error) {
if (!event.signal.aborted) console.error("pi-compaction: branch summary failed:", error);
return undefined;
}
});
pi.registerTool({
name: QUERY_TOOL_NAME,
label: "Query Compacted Tool History",
description: "Recover original tool outputs by source key or tool call id after pi-compaction replaced them with bounded summaries.",
promptSnippet: "Recover original compacted tool evidence",
promptGuidelines: [
"Use context_tree_query only when a pi-compaction summary lacks evidence needed for the current task.",
],
parameters: Type.Object({
toolCallIds: Type.Array(Type.String(), { minItems: 1, maxItems: 20 }),
}),
async execute(_id, params, _signal, _update, ctx) {
const records = service.query(ctx.sessionManager.getSessionId(), params.toolCallIds);
const blocks = records.map((record: ToolHistoryRecord) => {
const perResultBytes = Math.max(1_024, Math.floor(DEFAULT_MAX_BYTES / Math.max(1, records.length)));
const args = truncateHead(JSON.stringify(record.args), { maxLines: 20, maxBytes: 2_048 }).content;
const truncated = truncateHead(record.rawText, {
maxLines: Math.max(20, Math.floor(DEFAULT_MAX_LINES / Math.max(1, records.length))),
maxBytes: perResultBytes,
});
return [
`## ${record.sourceKey}`,
`Tool: ${record.toolName}`,
`Args: ${args}`,
`Status: ${record.isError ? "ERROR" : "OK"}`,
"",
truncated.content,
...(truncated.truncated ? [`[truncated: ${truncated.outputLines}/${truncated.totalLines} lines]`] : []),
].join("\n");
});
const missing = params.toolCallIds.filter((id: string) => !records.some((record) => record.sourceKey === id || record.toolCallId === id));
for (const id of missing) blocks.push(`## ${id}\n(not found)`);
return {
content: [{ type: "text", text: blocks.join("\n\n---\n\n") }],
details: { sourceKeys: records.map((record) => record.sourceKey), missing },
};
},
});
pi.registerCommand("compaction-status", {
description: "Show effective/raw context usage and pi-compaction state",
handler: async (_args, ctx) => {
const metrics = service.getMetrics(ctx.sessionManager.getSessionId());
const text = metrics
? [
`Effective: ${metrics.effectiveTokens}/${metrics.contextWindow} (${metrics.percent.toFixed(1)}%, ${metrics.confidence})`,
`Raw: ${metrics.rawTokens}/${metrics.contextWindow}`,
`Reclaimed: ${metrics.reclaimedTokens}`,
`Segments: ${metrics.segmentCount}; semantic jobs: ${metrics.semanticJobs}; generation: ${metrics.generation}`,
].join("\n")
: "pi-compaction has no projection snapshot yet.";
if (ctx.hasUI) ctx.ui.notify(text, "info");
else process.stderr.write(`${text}\n`);
},
});
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "pi-compaction",
"version": "1.0.0",
"description": "High-performance unified context projection and compaction for Pi",
"type": "module",
"main": "index.ts",
"files": [
"index.ts",
"src",
"README.md",
"CHANGELOG.md",
"UPSTREAM.md",
"LICENSES"
],
"scripts": {
"test": "node --import jiti/register --test test/*.test.ts",
"check": "tsc --noEmit -p tsconfig.json",
"pack:check": "npm pack --dry-run --json"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*",
"typebox": "*"
},
"engines": {
"node": ">=22.19.0"
},
"license": "MIT"
}
+88
View File
@@ -0,0 +1,88 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import type { PiCompactionConfig } from "./types.js";
export const SETTINGS_KEY = "piCompaction" as const;
export const DEFAULT_CONFIG: PiCompactionConfig = {
enabled: true,
softRatio: 0.72,
hardRatio: 0.9,
targetRatio: 0.55,
deterministic: {
enabled: true,
maxStubTokens: 256,
},
semantic: {
enabled: true,
model: "default",
thinking: "low",
concurrency: 1,
minInputChars: 4_000,
maxInputChars: 24_000,
maxRetries: 2,
},
native: {
enabled: true,
},
};
function ratio(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0 && value < 1 ? value : fallback;
}
function integer(value: unknown, fallback: number, min = 0, max = Number.MAX_SAFE_INTEGER): number {
return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max ? value : fallback;
}
function object(value: unknown): Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
export function normalizeConfig(value: unknown): PiCompactionConfig {
const input = object(value);
const deterministic = object(input.deterministic);
const semantic = object(input.semantic);
const native = object(input.native);
const softRatio = ratio(input.softRatio, DEFAULT_CONFIG.softRatio);
const hardRatio = Math.max(softRatio + 0.01, ratio(input.hardRatio, DEFAULT_CONFIG.hardRatio));
const targetRatio = Math.min(softRatio - 0.01, ratio(input.targetRatio, DEFAULT_CONFIG.targetRatio));
const thinking = ["off", "minimal", "low", "medium", "high", "xhigh"].includes(String(semantic.thinking))
? semantic.thinking as PiCompactionConfig["semantic"]["thinking"]
: DEFAULT_CONFIG.semantic.thinking;
return {
enabled: typeof input.enabled === "boolean" ? input.enabled : DEFAULT_CONFIG.enabled,
softRatio,
hardRatio: Math.min(0.99, hardRatio),
targetRatio: Math.max(0.1, targetRatio),
deterministic: {
enabled: typeof deterministic.enabled === "boolean" ? deterministic.enabled : DEFAULT_CONFIG.deterministic.enabled,
maxStubTokens: integer(deterministic.maxStubTokens, DEFAULT_CONFIG.deterministic.maxStubTokens, 32, 4_096),
},
semantic: {
enabled: typeof semantic.enabled === "boolean" ? semantic.enabled : DEFAULT_CONFIG.semantic.enabled,
model: typeof semantic.model === "string" && semantic.model.trim() ? semantic.model.trim() : DEFAULT_CONFIG.semantic.model,
thinking,
concurrency: integer(semantic.concurrency, DEFAULT_CONFIG.semantic.concurrency, 1, 4),
minInputChars: integer(semantic.minInputChars, DEFAULT_CONFIG.semantic.minInputChars, 0, 1_000_000),
maxInputChars: integer(semantic.maxInputChars, DEFAULT_CONFIG.semantic.maxInputChars, 1_000, 1_000_000),
maxRetries: integer(semantic.maxRetries, DEFAULT_CONFIG.semantic.maxRetries, 0, 5),
},
native: {
enabled: typeof native.enabled === "boolean" ? native.enabled : DEFAULT_CONFIG.native.enabled,
},
};
}
export async function loadConfig(): Promise<PiCompactionConfig> {
try {
const parsed = JSON.parse(await readFile(join(getAgentDir(), "settings.json"), "utf8"));
return normalizeConfig(object(parsed)[SETTINGS_KEY]);
} catch {
return structuredClone(DEFAULT_CONFIG);
}
}
+650
View File
@@ -0,0 +1,650 @@
import { createHash } from "node:crypto";
import { type SessionEntry, type ToolInfo } from "@earendil-works/pi-coding-agent";
export type Message = any;
export interface Model<_TApi = any> {
provider: string;
api: string;
id: string;
baseUrl?: string;
contextWindow?: number;
input: string[];
[key: string]: unknown;
}
export interface Usage {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
}
export const NATIVE_COMPACTION_KIND = "pi-compaction";
export const NATIVE_COMPACTION_VERSION = 1;
export const REMOTE_COMPACTION_FEATURE = "remote_compaction_v2";
export const RETAINED_USER_TOKEN_BUDGET = 64_000;
const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api";
const MAX_REMOTE_RETRIES = 2;
export type JsonObject = Record<string, unknown>;
export type ResponseItem = JsonObject & { type?: string };
export interface NativeCompactionDetails {
kind: typeof NATIVE_COMPACTION_KIND;
version: typeof NATIVE_COMPACTION_VERSION;
checkpoint: "native";
modelKey: string;
replacementHistory: ResponseItem[];
portableSummary: string;
sourceFingerprint: string;
}
export type NativeCheckpoint = {
entryIndex: number;
entryId: string;
details: NativeCompactionDetails;
};
export type CheckpointLookup =
| { status: "none" }
| { status: "invalid"; entryIndex: number; entryId: string }
| { status: "valid"; checkpoint: NativeCheckpoint };
export type RemoteCompactionResult = {
compactionItem: ResponseItem;
usage?: Usage;
};
export function isJsonObject(value: unknown): value is JsonObject {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function isOpenAICodexModel(model: unknown): model is Model<"openai-codex-responses"> {
if (!isJsonObject(model)) return false;
return model.provider === "openai-codex" && model.api === "openai-codex-responses";
}
export function modelKey(model: Pick<Model<any>, "provider" | "api" | "id">): string {
return `${model.provider}:${model.api}:${model.id}`;
}
function cloneItem<T>(value: T): T {
return structuredClone(value);
}
function isResponseItem(value: unknown): value is ResponseItem {
if (!isJsonObject(value)) return false;
return typeof value.type === "string" || (
typeof value.role === "string" && (typeof value.content === "string" || Array.isArray(value.content))
);
}
export function parseNativeCompactionDetails(value: unknown): NativeCompactionDetails | undefined {
if (!isJsonObject(value)) return undefined;
if (value.kind !== NATIVE_COMPACTION_KIND || value.version !== NATIVE_COMPACTION_VERSION) return undefined;
if (value.checkpoint !== "native" || typeof value.modelKey !== "string") return undefined;
if (typeof value.portableSummary !== "string" || typeof value.sourceFingerprint !== "string") return undefined;
if (!Array.isArray(value.replacementHistory)) return undefined;
const replacementHistory = value.replacementHistory.filter(isResponseItem);
if (replacementHistory.length !== value.replacementHistory.length || replacementHistory.length === 0) return undefined;
const compactionItems = replacementHistory.filter((item) => item.type === "compaction");
if (
compactionItems.length !== 1 ||
typeof compactionItems[0]?.encrypted_content !== "string" ||
replacementHistory.at(-1)?.type !== "compaction"
) return undefined;
return {
kind: NATIVE_COMPACTION_KIND,
version: NATIVE_COMPACTION_VERSION,
checkpoint: "native",
modelKey: value.modelKey,
replacementHistory: replacementHistory.map(cloneItem),
portableSummary: value.portableSummary,
sourceFingerprint: value.sourceFingerprint,
};
}
export function findNativeCheckpoint(branch: SessionEntry[]): CheckpointLookup {
for (let index = branch.length - 1; index >= 0; index--) {
const entry = branch[index];
if (!entry) continue;
if (entry.type !== "compaction") continue;
if (!isJsonObject(entry.details) || entry.details.kind !== NATIVE_COMPACTION_KIND) {
return { status: "none" };
}
const rawDetails: unknown = entry.details;
if ((rawDetails as JsonObject).checkpoint === "portable") return { status: "none" };
const details = parseNativeCompactionDetails(rawDetails);
if (!details) return { status: "invalid", entryIndex: index, entryId: entry.id };
return {
status: "valid",
checkpoint: { entryIndex: index, entryId: entry.id, details },
};
}
return { status: "none" };
}
function shortHash(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 16);
}
function normalizedItemId(value: string | undefined): string | undefined {
if (!value) return undefined;
const sanitized = value.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64).replace(/_+$/, "");
return sanitized.startsWith("fc_") ? sanitized : `fc_${sanitized}`.slice(0, 64);
}
function textSignature(value: unknown): { id?: string; phase?: "commentary" | "final_answer" } {
if (typeof value !== "string" || !value) return {};
try {
const parsed = JSON.parse(value) as JsonObject;
return {
id: typeof parsed.id === "string" ? parsed.id : undefined,
phase: parsed.phase === "commentary" || parsed.phase === "final_answer" ? parsed.phase : undefined,
};
} catch {
return { id: value };
}
}
function contentToUserParts(content: unknown): unknown[] {
if (typeof content === "string") return content ? [{ type: "input_text", text: content }] : [];
if (!Array.isArray(content)) return [];
const parts: unknown[] = [];
for (const part of content) {
if (!isJsonObject(part)) continue;
if (part.type === "text" && typeof part.text === "string") {
parts.push({ type: "input_text", text: part.text });
} else if (part.type === "image" && typeof part.data === "string" && typeof part.mimeType === "string") {
parts.push({ type: "input_image", detail: "auto", image_url: `data:${part.mimeType};base64,${part.data}` });
}
}
return parts;
}
function toolResultOutput(message: JsonObject, model: Model<any>): unknown {
const content = Array.isArray(message.content) ? message.content : [];
const text = content
.flatMap((part) => isJsonObject(part) && part.type === "text" && typeof part.text === "string" ? [part.text] : [])
.join("\n");
const images = content.filter((part) => isJsonObject(part) && part.type === "image");
if (images.length === 0 || !model.input.includes("image")) {
return text || (images.length > 0 ? "(see attached image)" : "(no tool output)");
}
return [
...(text ? [{ type: "input_text", text }] : []),
...images.flatMap((part) =>
typeof part.data === "string" && typeof part.mimeType === "string"
? [{ type: "input_image", detail: "auto", image_url: `data:${part.mimeType};base64,${part.data}` }]
: [],
),
];
}
function responseTool(tool: ToolInfo, deferLoading = false): JsonObject {
return {
type: "function",
name: tool.name,
description: tool.description,
parameters: tool.parameters as unknown,
strict: null,
...(deferLoading ? { defer_loading: true } : {}),
};
}
export function messagesToResponseItems(model: Model<any>, messages: Message[], tools: ToolInfo[]): ResponseItem[] {
const items: ResponseItem[] = [];
const toolsByName = new Map(tools.map((tool) => [tool.name, tool]));
const pendingToolCalls = new Map<string, string>();
const flushOrphanedToolCalls = () => {
for (const callId of pendingToolCalls.values()) {
items.push({ type: "function_call_output", call_id: callId, output: "No result provided" });
}
pendingToolCalls.clear();
};
let messageIndex = 0;
for (const message of messages as unknown as JsonObject[]) {
if (message.role === "user") {
flushOrphanedToolCalls();
const content = contentToUserParts(message.content);
if (content.length > 0) items.push({ role: "user", content });
} else if (message.role === "assistant" && Array.isArray(message.content)) {
flushOrphanedToolCalls();
if (message.stopReason === "error" || message.stopReason === "aborted") {
messageIndex++;
continue;
}
const isSameProviderAndApi = message.provider === model.provider && message.api === model.api;
let textIndex = 0;
for (const block of message.content) {
if (!isJsonObject(block)) continue;
if (block.type === "thinking" && typeof block.thinkingSignature === "string") {
if (isSameProviderAndApi) {
try {
const reasoning = JSON.parse(block.thinkingSignature);
if (isJsonObject(reasoning) && reasoning.type === "reasoning") {
const item = cloneItem(reasoning);
delete item.status;
items.push(item);
}
} catch {}
}
continue;
}
if (block.type === "text" && typeof block.text === "string") {
const signature = isSameProviderAndApi ? textSignature(block.textSignature) : {};
const fallbackId = textIndex === 0 ? `msg_pi_${messageIndex}` : `msg_pi_${messageIndex}_${textIndex}`;
textIndex++;
const rawId = signature.id || fallbackId;
const id = rawId.length <= 64 ? rawId : `msg_${shortHash(rawId)}`;
items.push({
type: "message",
role: "assistant",
id,
content: [{ type: "output_text", text: block.text, annotations: [] }],
...(signature.phase ? { phase: signature.phase } : {}),
});
continue;
}
if (block.type === "toolCall" && typeof block.id === "string") {
const [callId, rawItemId] = block.id.split("|");
const itemId = isSameProviderAndApi ? normalizedItemId(rawItemId) : undefined;
pendingToolCalls.set(block.id, callId);
items.push({
type: "function_call",
call_id: callId,
...(itemId ? { id: itemId } : {}),
name: String(block.name ?? ""),
arguments: JSON.stringify(block.arguments ?? {}),
});
}
}
} else if (message.role === "toolResult" && typeof message.toolCallId === "string") {
const [callId] = message.toolCallId.split("|");
pendingToolCalls.delete(message.toolCallId);
items.push({ type: "function_call_output", call_id: callId, output: toolResultOutput(message, model) });
const addedTools = Array.isArray(message.addedToolNames)
? message.addedToolNames.flatMap((name) => typeof name === "string" && toolsByName.has(name) ? [toolsByName.get(name)!] : [])
: [];
if (addedTools.length > 0) {
const searchCallId = `pi_tool_load_${shortHash(`${message.toolCallId}:${addedTools.map((tool) => tool.name).join(",")}`)}`;
items.push({
type: "tool_search_call",
call_id: searchCallId,
execution: "client",
status: "completed",
arguments: { query: addedTools.map((tool) => tool.name).join(" "), limit: addedTools.length },
});
items.push({
type: "tool_search_output",
call_id: searchCallId,
execution: "client",
status: "completed",
tools: addedTools.map((tool) => responseTool(tool, true)),
});
}
}
messageIndex++;
}
flushOrphanedToolCalls();
return items;
}
function responseItemText(item: ResponseItem): string {
if (item.type !== "message" && item.type !== undefined) return "";
if (typeof item.content === "string") return item.content;
if (!Array.isArray(item.content)) return "";
return item.content
.flatMap((part) =>
isJsonObject(part) && typeof part.text === "string" ? [part.text] : [],
)
.join("");
}
function approximateTokens(item: ResponseItem): number {
return Math.max(1, Math.ceil(responseItemText(item).length / 4));
}
function truncateMiddle(text: string, maxCharacters: number): string {
if (text.length <= maxCharacters) return text;
if (maxCharacters <= 1) return text.slice(-maxCharacters);
const marker = "…";
const available = Math.max(0, maxCharacters - marker.length);
const head = Math.ceil(available / 2);
const tail = Math.floor(available / 2);
return `${text.slice(0, head)}${marker}${tail > 0 ? text.slice(-tail) : ""}`;
}
function truncateMessage(item: ResponseItem, maxTokens: number): ResponseItem | undefined {
if ((item.type !== "message" && item.type !== undefined) || maxTokens <= 0) return undefined;
const copy = cloneItem(item);
let remainingCharacters = maxTokens * 4;
if (typeof copy.content === "string") {
copy.content = truncateMiddle(copy.content, remainingCharacters);
return copy.content ? copy : undefined;
}
if (!Array.isArray(copy.content)) return copy;
const content = copy.content;
const textParts = content.filter((part) => isJsonObject(part) && typeof part.text === "string");
const totalText = textParts.reduce((sum, part) => sum + String(part.text).length, 0);
let consumed = 0;
const truncatedContent = content.flatMap((part) => {
if (!isJsonObject(part) || typeof part.text !== "string") return [part];
const remainingText = totalText - consumed;
const partBudget = remainingText === 0 ? 0 : Math.floor((part.text.length / remainingText) * remainingCharacters);
const text = truncateMiddle(part.text, partBudget);
consumed += part.text.length;
remainingCharacters -= partBudget;
return text ? [{ ...part, text }] : [];
});
copy.content = truncatedContent;
return truncatedContent.length > 0 ? copy : undefined;
}
export function retainRecentUserMessages(items: ResponseItem[], maxTokens = RETAINED_USER_TOKEN_BUDGET): ResponseItem[] {
let remaining = maxTokens;
const retained: ResponseItem[] = [];
for (const item of [...items].reverse()) {
if (remaining <= 0) break;
if ((item.type !== "message" && item.type !== undefined) || item.role !== "user" || !responseItemText(item).trim()) continue;
const tokens = approximateTokens(item);
if (tokens <= remaining) {
retained.push(cloneItem(item));
remaining -= tokens;
continue;
}
const truncated = truncateMessage(item, remaining);
if (truncated) retained.push(truncated);
remaining = 0;
}
return retained.reverse();
}
export function buildReplacementHistory(
preCompactionInput: ResponseItem[],
compactionItem: ResponseItem,
maxRetainedTokens = RETAINED_USER_TOKEN_BUDGET,
): ResponseItem[] {
if (compactionItem.type !== "compaction" || typeof compactionItem.encrypted_content !== "string") {
throw new Error("OpenAI Codex did not return a valid compaction item.");
}
return [...retainRecentUserMessages(preCompactionInput, maxRetainedTokens), cloneItem(compactionItem)];
}
export function buildToolPayload(allTools: ToolInfo[], activeToolNames: string[]): unknown[] | undefined {
const active = new Set(activeToolNames);
const tools = allTools.filter((tool) => active.has(tool.name));
return tools.length > 0 ? tools.map((tool) => responseTool(tool)) : undefined;
}
export function buildCompactionRequestBody(params: {
basePayload?: JsonObject;
model: Model<any>;
input: ResponseItem[];
instructions: string;
tools?: unknown[];
sessionId: string;
}): JsonObject {
const base = params.basePayload ? cloneItem(params.basePayload) : {};
const previousText = isJsonObject(base.text) ? base.text : undefined;
const include = Array.isArray(base.include)
? [...new Set([...base.include.filter((value): value is string => typeof value === "string"), "reasoning.encrypted_content"])]
: ["reasoning.encrypted_content"];
const body: JsonObject = {
...base,
model: params.model.id,
store: false,
stream: true,
instructions: params.instructions,
input: [...params.input.map(cloneItem), { type: "compaction_trigger" }],
tool_choice: "auto",
parallel_tool_calls: true,
include,
prompt_cache_key: params.sessionId,
text: previousText && typeof previousText.verbosity === "string"
? { verbosity: previousText.verbosity }
: { verbosity: "low" },
};
if (params.tools) body.tools = params.tools;
else delete body.tools;
delete body.messages;
delete body.previous_response_id;
return body;
}
export function resolveCodexResponsesUrl(baseUrl?: string): string {
const normalized = (baseUrl?.trim() || DEFAULT_CODEX_BASE_URL).replace(/\/+$/, "");
if (normalized.endsWith("/codex/responses")) return normalized;
if (normalized.endsWith("/codex")) return `${normalized}/responses`;
return `${normalized}/codex/responses`;
}
export function extractCodexAccountId(token: string): string {
try {
const parts = token.split(".");
if (parts.length !== 3) throw new Error("Invalid token");
const payload = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf8")) as JsonObject;
const auth = payload["https://api.openai.com/auth"];
if (!isJsonObject(auth) || typeof auth.chatgpt_account_id !== "string") throw new Error("Missing account ID");
return auth.chatgpt_account_id;
} catch {
throw new Error("Failed to extract the ChatGPT account ID from the OpenAI Codex token.");
}
}
export function mergeFeatureHeader(existing: string | null | undefined): string {
const features = (existing ?? "")
.split(",")
.map((value) => value.trim())
.filter(Boolean);
return [...new Set([...features, REMOTE_COMPACTION_FEATURE])].join(",");
}
export function buildCodexHeaders(params: {
apiKey: string;
headers?: Record<string, string>;
sessionId: string;
}): Headers {
const headers = new Headers(params.headers);
headers.set("authorization", `Bearer ${params.apiKey}`);
headers.set("chatgpt-account-id", extractCodexAccountId(params.apiKey));
headers.set("originator", "pi");
headers.set("user-agent", "pi-compaction");
headers.set("OpenAI-Beta", "responses=experimental");
headers.set("accept", "text/event-stream");
headers.set("content-type", "application/json");
headers.set("session-id", params.sessionId);
headers.set("x-client-request-id", params.sessionId);
headers.set("x-codex-beta-features", mergeFeatureHeader(headers.get("x-codex-beta-features")));
return headers;
}
function parseRetryDelay(response: Response): number | undefined {
const milliseconds = Number(response.headers.get("retry-after-ms"));
if (Number.isFinite(milliseconds) && milliseconds >= 0) return milliseconds;
const retryAfter = response.headers.get("retry-after");
if (!retryAfter) return undefined;
const seconds = Number(retryAfter);
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
const date = Date.parse(retryAfter);
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
}
function isRetryableStatus(status: number): boolean {
return status === 408 || status === 409 || status === 429 || status >= 500;
}
class NonRetryableCompactionError extends Error {}
class RetryableCompactionStreamError extends Error {}
async function delay(ms: number, signal?: AbortSignal): Promise<void> {
if (ms <= 0) return;
await new Promise<void>((resolve, reject) => {
const cleanup = () => signal?.removeEventListener("abort", onAbort);
const timer = setTimeout(() => {
cleanup();
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timer);
cleanup();
reject(signal?.reason instanceof Error ? signal.reason : new Error("Compaction aborted"));
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}
async function parseSseResponse(response: Response): Promise<{ item: ResponseItem; usage?: unknown }> {
if (!response.body) throw new Error("OpenAI Codex returned an empty compaction stream.");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let completed = false;
let usage: unknown;
const compactionItems: ResponseItem[] = [];
const processBlock = (block: string) => {
const data = block
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trimStart())
.join("\n")
.trim();
if (!data || data === "[DONE]") return;
let event: unknown;
try {
event = JSON.parse(data);
} catch {
throw new NonRetryableCompactionError("OpenAI Codex returned malformed compaction SSE data.");
}
if (!isJsonObject(event)) return;
if (event.type === "error") {
if (typeof event.message !== "string" || !event.message.trim()) {
throw new RetryableCompactionStreamError("OpenAI Codex compaction failed.");
}
throw new NonRetryableCompactionError(event.message);
}
if (event.type === "response.failed") {
throw new NonRetryableCompactionError("OpenAI Codex compaction ended with response.failed.");
}
if (event.type === "response.incomplete") {
throw new RetryableCompactionStreamError("OpenAI Codex compaction ended with response.incomplete.");
}
if (event.type === "response.output_item.done" && isResponseItem(event.item) && event.item.type === "compaction") {
compactionItems.push(event.item);
}
if (event.type === "response.completed" || event.type === "response.done") {
completed = true;
usage = isJsonObject(event.response) ? event.response.usage : undefined;
}
};
while (true) {
const { done, value } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
buffer = buffer.replace(/\r\n/g, "\n");
let boundary = buffer.indexOf("\n\n");
while (boundary >= 0) {
processBlock(buffer.slice(0, boundary));
buffer = buffer.slice(boundary + 2);
boundary = buffer.indexOf("\n\n");
}
if (done) break;
}
if (buffer.trim()) processBlock(buffer);
if (!completed) {
throw new RetryableCompactionStreamError(
"OpenAI Codex compaction stream closed before response.completed.",
);
}
if (compactionItems.length !== 1) {
throw new NonRetryableCompactionError(
`OpenAI Codex returned ${compactionItems.length} compaction items; expected exactly one.`,
);
}
const item = compactionItems[0]!;
if (typeof item.encrypted_content !== "string") {
throw new NonRetryableCompactionError(
"OpenAI Codex returned a compaction item without encrypted_content.",
);
}
return { item, usage };
}
function usageFromResponse(value: unknown): Usage | undefined {
if (!isJsonObject(value)) return undefined;
const inputTokens = typeof value.input_tokens === "number" ? value.input_tokens : 0;
const outputTokens = typeof value.output_tokens === "number" ? value.output_tokens : 0;
const details = isJsonObject(value.input_tokens_details) ? value.input_tokens_details : undefined;
const cacheRead = typeof details?.cached_tokens === "number" ? details.cached_tokens : 0;
const cacheWrite = typeof details?.cache_write_tokens === "number" ? details.cache_write_tokens : 0;
const usage: Usage = {
input: Math.max(0, inputTokens - cacheRead - cacheWrite),
output: outputTokens,
cacheRead,
cacheWrite,
totalTokens: typeof value.total_tokens === "number" ? value.total_tokens : inputTokens + outputTokens,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
return usage;
}
export async function callRemoteCompaction(params: {
url: string;
headers: Headers;
body: JsonObject;
signal?: AbortSignal;
fetchImpl?: typeof fetch;
}): Promise<RemoteCompactionResult> {
const fetchImpl = params.fetchImpl ?? fetch;
let lastError: unknown;
for (let attempt = 0; attempt <= MAX_REMOTE_RETRIES; attempt++) {
try {
const response = await fetchImpl(params.url, {
method: "POST",
headers: params.headers,
body: JSON.stringify(params.body),
signal: params.signal,
});
if (!response.ok) {
const body = await response.text().catch(() => "");
const message = `OpenAI Codex compaction failed (${response.status}): ${body || response.statusText}`;
if (!isRetryableStatus(response.status)) throw new NonRetryableCompactionError(message);
const error = new Error(message);
if (attempt === MAX_REMOTE_RETRIES) throw error;
lastError = error;
await delay(parseRetryDelay(response) ?? 1000 * 2 ** attempt, params.signal);
continue;
}
const parsed = await parseSseResponse(response);
return { compactionItem: parsed.item, usage: usageFromResponse(parsed.usage) };
} catch (error) {
if (params.signal?.aborted || error instanceof NonRetryableCompactionError) throw error;
lastError = error;
if (attempt === MAX_REMOTE_RETRIES) throw error;
await delay(1000 * 2 ** attempt, params.signal);
}
}
throw lastError instanceof Error ? lastError : new Error("OpenAI Codex compaction failed.");
}
export function stripInputFromPayload(payload: JsonObject): JsonObject {
const shape = cloneItem(payload);
delete shape.input;
delete shape.messages;
delete shape.previous_response_id;
return shape;
}
+381
View File
@@ -0,0 +1,381 @@
import { createHash } from "node:crypto";
import { estimateTokens } from "@earendil-works/pi-coding-agent";
import {
COMPACTION_CUSTOM_TYPE,
COMPACTION_VERSION,
type PiCompactionConfig,
type ProjectionSnapshot,
type SegmentSummaryRecord,
type ToolHistoryRecord,
type UsageBaseline,
} from "./types.js";
function timestampOf(value: unknown): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Date.parse(value);
if (Number.isFinite(parsed)) return parsed;
}
return 0;
}
export function sourceKey(toolCallId: string, timestamp: unknown): string {
return `${toolCallId}@${timestampOf(timestamp)}`;
}
export function contentText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.flatMap((part) => part && typeof part === "object" && (part as any).type === "text" && typeof (part as any).text === "string"
? [(part as any).text]
: [])
.join("\n");
}
function hash(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 20);
}
function json(value: unknown): string {
try {
return JSON.stringify(value) ?? "undefined";
} catch {
return "[unserializable]";
}
}
function boundedEvidence(text: string, maxChars: number): string {
const normalized = text.replace(/\u001b\[[0-9;]*m/g, "").trim();
if (normalized.length <= maxChars) return normalized;
const marker = `\n… ${normalized.length - maxChars} chars omitted …\n`;
const available = Math.max(0, maxChars - marker.length);
const head = Math.ceil(available * 0.7);
const tail = Math.max(0, available - head);
return `${normalized.slice(0, head)}${marker}${tail ? normalized.slice(-tail) : ""}`;
}
export function deterministicSummary(params: {
toolName: string;
args: unknown;
rawText: string;
isError: boolean;
maxStubTokens: number;
}): string {
const maxChars = params.maxStubTokens * 4;
const argsText = boundedEvidence(json(params.args), Math.min(600, Math.floor(maxChars * 0.25)));
const evidenceBudget = Math.max(128, maxChars - argsText.length - 120);
const evidence = boundedEvidence(params.rawText || "(no output)", evidenceBudget);
return [
`[pi-compaction tool=${params.toolName} status=${params.isError ? "error" : "ok"}]`,
`Args: ${argsText}`,
`Evidence:\n${evidence}`,
].join("\n");
}
function messageKey(message: any): string {
const role = String(message?.role ?? "unknown");
const timestamp = timestampOf(message?.timestamp);
if (role === "toolResult") return `${role}:${message.toolCallId ?? ""}:${timestamp}`;
if (role === "assistant") {
const blocks = Array.isArray(message.content) ? message.content : [];
const ids = blocks.flatMap((block: any) => block?.type === "toolCall" ? [block.id] : []).join(",");
return `${role}:${timestamp}:${ids}:${blocks.length}`;
}
return `${role}:${timestamp}:${typeof message?.content === "string" ? message.content.length : (message?.content?.length ?? 0)}`;
}
function isSummaryRecord(value: unknown): value is SegmentSummaryRecord {
if (!value || typeof value !== "object") return false;
const record = value as Partial<SegmentSummaryRecord>;
return record.version === COMPACTION_VERSION
&& record.kind === "segment-summary"
&& typeof record.sourceKey === "string"
&& typeof record.toolCallId === "string"
&& typeof record.resultTimestamp === "number"
&& typeof record.toolName === "string"
&& typeof record.sourceHash === "string"
&& typeof record.deterministicSummary === "string";
}
export class SegmentIndex {
private readonly records = new Map<string, ToolHistoryRecord>();
private readonly ids = new Map<string, string[]>();
private generation = 0;
private semanticJobs = 0;
private usageBaseline?: UsageBaseline;
private sourceMessages: any[] = [];
private sourceKeys: string[] = [];
private projectedMessages: any[] = [];
private rawEstimates: number[] = [];
private effectiveEstimates: number[] = [];
private cached?: ProjectionSnapshot;
readonly sessionId: string;
constructor(sessionId: string) {
this.sessionId = sessionId;
}
hydrate(branch: readonly any[], config: PiCompactionConfig): void {
this.reset();
if (!config.enabled) return;
const summaries = new Map<string, SegmentSummaryRecord>();
for (const entry of branch) {
if (entry?.type === "custom" && entry.customType === COMPACTION_CUSTOM_TYPE && isSummaryRecord(entry.data)) {
summaries.set(entry.data.sourceKey, entry.data);
}
}
const pendingCalls = new Map<string, { toolName: string; args: unknown }>();
for (const entry of branch) {
if (entry?.type !== "message") continue;
const message = entry.message;
if (message?.role === "assistant" && Array.isArray(message.content)) {
for (const block of message.content) {
if (block?.type === "toolCall" && typeof block.id === "string") {
pendingCalls.set(block.id, {
toolName: String(block.name ?? "unknown"),
args: block.input ?? block.arguments ?? block.args ?? {},
});
}
}
} else if (message?.role === "toolResult" && typeof message.toolCallId === "string") {
const call = pendingCalls.get(message.toolCallId) ?? {
toolName: String(message.toolName ?? "unknown"),
args: {},
};
const key = sourceKey(message.toolCallId, message.timestamp);
const rawText = contentText(message.content);
const persisted = summaries.get(key);
const record: ToolHistoryRecord = {
sourceKey: key,
toolCallId: message.toolCallId,
resultTimestamp: timestampOf(message.timestamp),
toolName: call.toolName,
args: call.args,
isError: Boolean(message.isError),
rawText,
sourceHash: hash(`${call.toolName}\0${json(call.args)}\0${rawText}`),
deterministicSummary: persisted?.deterministicSummary ?? deterministicSummary({
toolName: call.toolName,
args: call.args,
rawText,
isError: Boolean(message.isError),
maxStubTokens: config.deterministic.maxStubTokens,
}),
semanticSummary: persisted?.semanticSummary,
};
this.put(record);
pendingCalls.delete(message.toolCallId);
}
}
this.generation++;
}
reset(): void {
this.records.clear();
this.ids.clear();
this.generation = 0;
this.semanticJobs = 0;
this.usageBaseline = undefined;
this.sourceMessages = [];
this.sourceKeys = [];
this.projectedMessages = [];
this.rawEstimates = [];
this.effectiveEstimates = [];
this.cached = undefined;
}
private put(record: ToolHistoryRecord): void {
this.records.set(record.sourceKey, record);
const keys = this.ids.get(record.toolCallId) ?? [];
if (!keys.includes(record.sourceKey)) keys.push(record.sourceKey);
this.ids.set(record.toolCallId, keys);
}
captureTurn(message: any, toolResults: any[], config: PiCompactionConfig): ToolHistoryRecord[] {
if (!config.enabled || !config.deterministic.enabled || !Array.isArray(message?.content)) return [];
const calls = new Map<string, any>();
for (const block of message.content) {
if (block?.type === "toolCall" && typeof block.id === "string") calls.set(block.id, block);
}
const captured: ToolHistoryRecord[] = [];
for (const result of toolResults ?? []) {
if (!result || typeof result.toolCallId !== "string") continue;
const call = calls.get(result.toolCallId);
if (!call) continue;
const rawText = contentText(result.content);
const toolName = String(call.name ?? result.toolName ?? "unknown");
const args = call.input ?? call.arguments ?? call.args ?? {};
const key = sourceKey(result.toolCallId, result.timestamp);
const record: ToolHistoryRecord = {
sourceKey: key,
toolCallId: result.toolCallId,
resultTimestamp: timestampOf(result.timestamp),
toolName,
args,
isError: Boolean(result.isError),
rawText,
sourceHash: hash(`${toolName}\0${json(args)}\0${rawText}`),
deterministicSummary: deterministicSummary({
toolName,
args,
rawText,
isError: Boolean(result.isError),
maxStubTokens: config.deterministic.maxStubTokens,
}),
};
this.put(record);
captured.push(record);
}
if (captured.length) {
this.generation++;
this.cached = undefined;
}
return captured;
}
applySemantic(sourceKeyValue: string, sourceHash: string, summary: string): SegmentSummaryRecord | undefined {
const record = this.records.get(sourceKeyValue);
if (!record || record.sourceHash !== sourceHash || !summary.trim()) return undefined;
record.semanticSummary = summary.trim();
this.generation++;
this.cached = undefined;
return this.toPersisted(record);
}
toPersisted(record: ToolHistoryRecord): SegmentSummaryRecord {
const effective = record.semanticSummary ?? record.deterministicSummary;
return {
version: COMPACTION_VERSION,
kind: "segment-summary",
sourceKey: record.sourceKey,
toolCallId: record.toolCallId,
resultTimestamp: record.resultTimestamp,
toolName: record.toolName,
sourceHash: record.sourceHash,
deterministicSummary: record.deterministicSummary,
...(record.semanticSummary ? { semanticSummary: record.semanticSummary } : {}),
rawChars: record.rawText.length,
effectiveChars: effective.length,
};
}
setSemanticJobs(count: number): void {
this.semanticJobs = Math.max(0, count);
if (this.cached) this.cached = { ...this.cached, semanticJobs: this.semanticJobs };
}
recordUsage(tokens: number, messageEstimate: number, environmentHash: string): void {
if (!Number.isFinite(tokens) || tokens <= 0) return;
this.usageBaseline = { tokens, messageEstimate, environmentHash };
this.cached = undefined;
}
project(messages: readonly any[], params: {
environmentHash: string;
environmentTokens: number;
contextWindow: number;
}): ProjectionSnapshot {
const sameTail = messages.length === this.sourceMessages.length
&& messages.length > 0
&& messageKey(messages[messages.length - 1]) === this.sourceKeys[this.sourceKeys.length - 1];
if (this.cached && sameTail && this.cached.environmentHash === params.environmentHash) return this.cached;
const appendOnly = this.sourceMessages.length > 0
&& messages.length >= this.sourceMessages.length
&& messageKey(messages[this.sourceMessages.length - 1]) === this.sourceKeys[this.sourceKeys.length - 1]
&& this.cached?.environmentHash === params.environmentHash;
let start = 0;
if (!appendOnly) {
this.sourceMessages = [];
this.sourceKeys = [];
this.projectedMessages = [];
this.rawEstimates = [];
this.effectiveEstimates = [];
} else {
start = this.sourceMessages.length;
}
for (let index = start; index < messages.length; index++) {
const message = messages[index];
const projected = this.projectMessage(message);
this.sourceMessages.push(message);
this.sourceKeys.push(messageKey(message));
this.projectedMessages.push(projected);
this.rawEstimates.push(estimateTokens(message as any));
this.effectiveEstimates.push(estimateTokens(projected as any));
}
const rawMessageTokens = this.rawEstimates.reduce((sum, value) => sum + value, 0);
const effectiveMessageTokens = this.effectiveEstimates.reduce((sum, value) => sum + value, 0);
const rawTokens = rawMessageTokens + params.environmentTokens;
const baseline = this.usageBaseline;
const usageBacked = baseline?.environmentHash === params.environmentHash;
const effectiveTokens = usageBacked
? Math.max(0, baseline.tokens + effectiveMessageTokens - baseline.messageEstimate)
: effectiveMessageTokens + params.environmentTokens;
const confidence = usageBacked ? "usage-backed" as const : "estimated" as const;
const lowerBound = Math.max(0, Math.floor(effectiveTokens * (usageBacked ? 0.98 : 0.9)));
const upperBound = Math.ceil(effectiveTokens * (usageBacked ? 1.05 : 1.2));
const fingerprint = hash(`${this.sessionId}:${this.generation}:${params.environmentHash}:${this.sourceKeys.join("|")}`);
this.cached = {
sessionId: this.sessionId,
generation: this.generation,
fingerprint,
environmentHash: params.environmentHash,
messages: this.projectedMessages,
messageEstimate: effectiveMessageTokens,
rawTokens,
effectiveTokens,
reclaimedTokens: Math.max(0, rawTokens - effectiveTokens),
contextWindow: params.contextWindow,
confidence,
lowerBound,
upperBound,
segmentCount: this.sourceMessages.length,
semanticJobs: this.semanticJobs,
};
return this.cached;
}
private projectMessage(message: any): any {
if (message?.role !== "toolResult" || typeof message.toolCallId !== "string") return message;
const record = this.records.get(sourceKey(message.toolCallId, message.timestamp));
if (!record) return message;
const summary = record.semanticSummary ?? record.deterministicSummary;
return {
...message,
content: [{ type: "text", text: `${summary}\nSource: ${record.sourceKey}; recover with context_tree_query.` }],
};
}
query(ids: string[]): ToolHistoryRecord[] {
const found: ToolHistoryRecord[] = [];
const seen = new Set<string>();
for (const id of ids) {
const direct = this.records.get(id);
const keys = direct ? [id] : this.ids.get(id) ?? [];
for (const key of keys) {
if (seen.has(key)) continue;
const record = this.records.get(key);
if (record) {
found.push(record);
seen.add(key);
}
}
}
return found;
}
allRecords(): ToolHistoryRecord[] {
return [...this.records.values()];
}
getSnapshot(): ProjectionSnapshot | undefined {
return this.cached;
}
}
+189
View File
@@ -0,0 +1,189 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { PiCompactionConfig, ToolHistoryRecord } from "./types.js";
interface Job {
sessionId: string;
sourceKey: string;
sourceHash: string;
toolName: string;
args: unknown;
isError: boolean;
rawText: string;
ctx: ExtensionContext;
config: PiCompactionConfig;
controller: AbortController;
}
export interface SemanticResult {
sessionId: string;
sourceKey: string;
sourceHash: string;
summary: string;
}
function modelFor(config: PiCompactionConfig, ctx: ExtensionContext): any {
if (config.semantic.model === "default") return ctx.model;
const slash = config.semantic.model.indexOf("/");
if (slash <= 0) return ctx.model;
return ctx.modelRegistry.find(
config.semantic.model.slice(0, slash),
config.semantic.model.slice(slash + 1),
) ?? ctx.model;
}
function serialize(job: Job): string {
const args = (() => {
try { return JSON.stringify(job.args); } catch { return "[unserializable]"; }
})();
const max = job.config.semantic.maxInputChars;
const raw = job.rawText.length <= max
? job.rawText
: `${job.rawText.slice(0, Math.floor(max * 0.7))}\n… omitted …\n${job.rawText.slice(-Math.floor(max * 0.3))}`;
return `Summarize this completed coding-agent tool call for future context.\nKeep concrete outcomes, paths, identifiers, values, and exact errors.\nUse at most 6 concise bullets. Do not give advice and do not call tools.\n\nTool: ${job.toolName}\nStatus: ${job.isError ? "ERROR" : "OK"}\nArgs: ${args}\nOutput:\n${raw}`;
}
async function run(job: Job): Promise<string> {
const model = modelFor(job.config, job.ctx);
if (!model) throw new Error("No summarizer model selected");
const message = await job.ctx.modelRegistry.complete(model, {
messages: [{
role: "user",
content: [{ type: "text", text: serialize(job) }],
timestamp: Date.now(),
}],
} as any, {
signal: job.controller.signal,
reasoningEffort: job.config.semantic.thinking === "off" ? undefined : job.config.semantic.thinking,
} as any);
if (message.stopReason === "length" || message.stopReason === "error" || message.stopReason === "aborted") {
throw new Error(message.errorMessage ?? `Semantic compaction stopped: ${message.stopReason}`);
}
const text = message.content
.flatMap((part: any) => part.type === "text" && typeof part.text === "string" ? [part.text] : [])
.join("\n")
.trim();
if (!text) throw new Error("Semantic compaction returned an empty summary");
return text;
}
function abortableDelay(ms: number, signal: AbortSignal): Promise<void> {
if (signal.aborted) return Promise.reject(new Error("Semantic compaction aborted"));
return new Promise((resolve, reject) => {
const timer = setTimeout(done, ms);
function done(): void {
signal.removeEventListener("abort", aborted);
resolve();
}
function aborted(): void {
clearTimeout(timer);
signal.removeEventListener("abort", aborted);
reject(new Error("Semantic compaction aborted"));
}
signal.addEventListener("abort", aborted, { once: true });
});
}
export class SemanticWorker {
private readonly queue: Job[] = [];
private readonly active = new Map<string, Job>();
private readonly ready: SemanticResult[] = [];
private readonly queuedKeys = new Set<string>();
enqueue(sessionId: string, record: ToolHistoryRecord, ctx: ExtensionContext, config: PiCompactionConfig): void {
if (!config.semantic.enabled || record.semanticSummary || record.rawText.length < config.semantic.minInputChars) return;
const key = `${sessionId}:${record.sourceKey}:${record.sourceHash}`;
if (this.queuedKeys.has(key)) return;
this.queuedKeys.add(key);
this.queue.push({
sessionId,
sourceKey: record.sourceKey,
sourceHash: record.sourceHash,
toolName: record.toolName,
args: record.args,
isError: record.isError,
rawText: record.rawText,
ctx,
config,
controller: new AbortController(),
});
this.pump(config.semantic.concurrency);
}
private pump(limit: number): void {
while (this.active.size < limit && this.queue.length > 0) {
const job = this.queue.shift()!;
const key = `${job.sessionId}:${job.sourceKey}:${job.sourceHash}`;
this.active.set(key, job);
void this.execute(key, job, limit);
}
}
private async execute(key: string, job: Job, limit: number): Promise<void> {
let lastError: unknown;
try {
for (let attempt = 0; attempt <= job.config.semantic.maxRetries; attempt++) {
try {
const summary = await run(job);
if (!job.controller.signal.aborted) {
this.ready.push({
sessionId: job.sessionId,
sourceKey: job.sourceKey,
sourceHash: job.sourceHash,
summary,
});
}
return;
} catch (error) {
lastError = error;
if (job.controller.signal.aborted || attempt === job.config.semantic.maxRetries) break;
await abortableDelay(Math.min(8_000, 1_000 * 2 ** attempt), job.controller.signal);
}
}
if (!job.controller.signal.aborted) {
console.error(`pi-compaction: semantic summary failed for ${job.sourceKey}:`, lastError);
}
} finally {
this.active.delete(key);
this.queuedKeys.delete(key);
this.pump(limit);
}
}
drain(sessionId: string): SemanticResult[] {
const drained: SemanticResult[] = [];
for (let index = this.ready.length - 1; index >= 0; index--) {
if (this.ready[index]!.sessionId !== sessionId) continue;
drained.push(this.ready[index]!);
this.ready.splice(index, 1);
}
return drained.reverse();
}
count(sessionId: string): number {
return this.queue.filter((job) => job.sessionId === sessionId).length
+ [...this.active.values()].filter((job) => job.sessionId === sessionId).length;
}
cancelSession(sessionId: string): void {
for (const job of this.active.values()) {
if (job.sessionId === sessionId) job.controller.abort();
}
for (let index = this.queue.length - 1; index >= 0; index--) {
if (this.queue[index]!.sessionId === sessionId) {
const job = this.queue[index]!;
this.queuedKeys.delete(`${job.sessionId}:${job.sourceKey}:${job.sourceHash}`);
this.queue.splice(index, 1);
}
}
for (let index = this.ready.length - 1; index >= 0; index--) {
if (this.ready[index]!.sessionId === sessionId) this.ready.splice(index, 1);
}
}
cancelAll(): void {
for (const job of this.active.values()) job.controller.abort();
this.queue.length = 0;
this.ready.length = 0;
this.queuedKeys.clear();
}
}
+80
View File
@@ -0,0 +1,80 @@
import {
SERVICE_SYMBOL,
type CompactionMetrics,
type CompactionService,
type ProjectionSnapshot,
type ToolHistoryRecord,
} from "./types.js";
interface GlobalWithCompactionService {
[SERVICE_SYMBOL]?: CompactionService;
}
const root = globalThis as GlobalWithCompactionService;
export class CompactionServiceRegistry implements CompactionService {
private readonly snapshots = new Map<string, ProjectionSnapshot>();
private readonly queryHandlers = new Map<string, (ids: string[]) => ToolHistoryRecord[]>();
setSession(
sessionId: string,
query: (ids: string[]) => ToolHistoryRecord[],
): void {
this.queryHandlers.set(sessionId, query);
}
setSnapshot(snapshot: ProjectionSnapshot): void {
this.snapshots.set(snapshot.sessionId, snapshot);
}
clearSnapshot(sessionId: string): void {
this.snapshots.delete(sessionId);
}
deleteSession(sessionId: string): void {
this.snapshots.delete(sessionId);
this.queryHandlers.delete(sessionId);
}
clear(): void {
this.snapshots.clear();
this.queryHandlers.clear();
}
getSnapshot(sessionId: string): ProjectionSnapshot | undefined {
return this.snapshots.get(sessionId);
}
getMetrics(sessionId: string): CompactionMetrics | undefined {
const snapshot = this.snapshots.get(sessionId);
if (!snapshot) return undefined;
return {
sessionId,
generation: snapshot.generation,
rawTokens: snapshot.rawTokens,
effectiveTokens: snapshot.effectiveTokens,
reclaimedTokens: snapshot.reclaimedTokens,
contextWindow: snapshot.contextWindow,
percent: snapshot.contextWindow > 0 ? 100 * snapshot.effectiveTokens / snapshot.contextWindow : 0,
confidence: snapshot.confidence,
segmentCount: snapshot.segmentCount,
semanticJobs: snapshot.semanticJobs,
};
}
query(sessionId: string, ids: string[]): ToolHistoryRecord[] {
return this.queryHandlers.get(sessionId)?.(ids) ?? [];
}
}
export function publishCompactionService(service: CompactionService): void {
root[SERVICE_SYMBOL] = service;
}
export function unpublishCompactionService(service: CompactionService): void {
if (root[SERVICE_SYMBOL] === service) delete root[SERVICE_SYMBOL];
}
export function getCompactionService(): CompactionService | undefined {
return root[SERVICE_SYMBOL];
}
+13
View File
@@ -0,0 +1,13 @@
import type { PiCompactionConfig, ProjectionSnapshot } from "./types.js";
export type CompactionReason = "manual" | "threshold" | "overflow";
export function shouldCompact(
reason: CompactionReason,
snapshot: Pick<ProjectionSnapshot, "lowerBound" | "contextWindow">,
config: Pick<PiCompactionConfig, "hardRatio">,
): boolean {
if (reason === "manual" || reason === "overflow") return true;
if (snapshot.contextWindow <= 0) return true;
return snapshot.lowerBound / snapshot.contextWindow >= config.hardRatio;
}
+119
View File
@@ -0,0 +1,119 @@
import type { ResponseItem } from "./native-compaction.js";
export const COMPACTION_CUSTOM_TYPE = "pi-compaction";
export const COMPACTION_VERSION = 1 as const;
export const QUERY_TOOL_NAME = "context_tree_query";
export const SERVICE_SYMBOL = Symbol.for("my-pi.pi-compaction.service.v1");
export interface PiCompactionConfig {
enabled: boolean;
softRatio: number;
hardRatio: number;
targetRatio: number;
deterministic: {
enabled: boolean;
maxStubTokens: number;
};
semantic: {
enabled: boolean;
model: string;
thinking: "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
concurrency: number;
minInputChars: number;
maxInputChars: number;
maxRetries: number;
};
native: {
enabled: boolean;
};
}
export interface SegmentSummaryRecord {
version: typeof COMPACTION_VERSION;
kind: "segment-summary";
sourceKey: string;
toolCallId: string;
resultTimestamp: number;
toolName: string;
sourceHash: string;
deterministicSummary: string;
semanticSummary?: string;
rawChars: number;
effectiveChars: number;
}
export type PiCompactionRecord = SegmentSummaryRecord;
export interface ToolHistoryRecord {
sourceKey: string;
toolCallId: string;
resultTimestamp: number;
toolName: string;
args: unknown;
isError: boolean;
rawText: string;
sourceHash: string;
deterministicSummary: string;
semanticSummary?: string;
}
export interface UsageBaseline {
tokens: number;
messageEstimate: number;
environmentHash: string;
}
export interface ProjectionSnapshot {
sessionId: string;
generation: number;
fingerprint: string;
environmentHash: string;
messages: any[];
messageEstimate: number;
rawTokens: number;
effectiveTokens: number;
reclaimedTokens: number;
contextWindow: number;
confidence: "usage-backed" | "estimated";
lowerBound: number;
upperBound: number;
segmentCount: number;
semanticJobs: number;
codexItems?: ResponseItem[];
}
export interface NativeCheckpointDetails {
kind: typeof COMPACTION_CUSTOM_TYPE;
version: typeof COMPACTION_VERSION;
checkpoint: "native";
modelKey: string;
replacementHistory: ResponseItem[];
portableSummary: string;
sourceFingerprint: string;
}
export interface PortableCheckpointDetails {
kind: typeof COMPACTION_CUSTOM_TYPE;
version: typeof COMPACTION_VERSION;
checkpoint: "portable";
sourceFingerprint: string;
}
export interface CompactionMetrics {
sessionId: string;
generation: number;
rawTokens: number;
effectiveTokens: number;
reclaimedTokens: number;
contextWindow: number;
percent: number;
confidence: ProjectionSnapshot["confidence"];
segmentCount: number;
semanticJobs: number;
}
export interface CompactionService {
getSnapshot(sessionId: string): ProjectionSnapshot | undefined;
getMetrics(sessionId: string): CompactionMetrics | undefined;
query(sessionId: string, ids: string[]): ToolHistoryRecord[];
}
+35
View File
@@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import { DEFAULT_CONFIG, normalizeConfig } from "../src/config.ts";
test("normalizes one greenfield piCompaction config without legacy aliases", () => {
const config = normalizeConfig({
enabled: false,
softRatio: 0.7,
hardRatio: 0.91,
targetRatio: 0.5,
deterministic: { enabled: false, maxStubTokens: 512 },
semantic: { enabled: false, concurrency: 2, maxRetries: 0 },
native: { enabled: false },
});
assert.equal(config.enabled, false);
assert.equal(config.deterministic.enabled, false);
assert.equal(config.deterministic.maxStubTokens, 512);
assert.equal(config.semantic.enabled, false);
assert.equal(config.semantic.maxRetries, 0);
assert.equal(config.native.enabled, false);
});
test("invalid ratios and bounds fail safe to bounded defaults", () => {
const config = normalizeConfig({
softRatio: 2,
hardRatio: -1,
targetRatio: 0.99,
semantic: { concurrency: 50, maxRetries: -1 },
});
assert.equal(config.softRatio, DEFAULT_CONFIG.softRatio);
assert.ok(config.targetRatio < config.softRatio);
assert.ok(config.hardRatio > config.softRatio);
assert.equal(config.semantic.concurrency, DEFAULT_CONFIG.semantic.concurrency);
assert.equal(config.semantic.maxRetries, DEFAULT_CONFIG.semantic.maxRetries);
});
@@ -0,0 +1,103 @@
import test from "node:test";
import assert from "node:assert/strict";
import { performance } from "node:perf_hooks";
import piCompaction from "../index.ts";
function harness() {
const handlers = new Map<string, Function[]>();
const tools = new Map<string, any>();
const commands = new Map<string, any>();
const entries: Array<{ customType: string; data: unknown }> = [];
const toolDefinitions = [{ name: "bash", description: "shell", parameters: { type: "object" } }];
const pi: any = {
on(name: string, handler: Function) {
const list = handlers.get(name) ?? [];
list.push(handler);
handlers.set(name, list);
},
appendEntry(customType: string, data: unknown) { entries.push({ customType, data }); },
registerTool(tool: any) { tools.set(tool.name, tool); },
registerCommand(name: string, command: any) { commands.set(name, command); },
getActiveTools() { return ["bash"]; },
getAllTools() { return toolDefinitions; },
};
return { pi, handlers, tools, commands, entries };
}
function context(branch: any[] = []) {
return {
sessionManager: {
getSessionId: () => "session-test",
getBranch: () => branch,
},
model: { provider: "test", api: "test", id: "model", contextWindow: 100_000 },
modelRegistry: {},
getSystemPrompt: () => "system",
getContextUsage: () => ({ tokens: 1_000, contextWindow: 100_000, percent: 1 }),
hasUI: false,
abort() {},
} as any;
}
test("one runtime projects tool evidence and recovers it through context_tree_query", async () => {
const h = harness();
piCompaction(h.pi);
const ctx = context();
await h.handlers.get("session_start")![0]!({}, ctx);
const assistant = {
role: "assistant",
content: [{ type: "toolCall", id: "call-1", name: "bash", arguments: { command: "npm test" } }],
timestamp: 10,
};
const result = {
role: "toolResult",
toolCallId: "call-1",
toolName: "bash",
content: [{ type: "text", text: "failure evidence" }],
isError: true,
timestamp: 11,
};
await h.handlers.get("turn_end")![0]!({ message: assistant, toolResults: [result] }, ctx);
const projected = await h.handlers.get("context")![0]!({ messages: [assistant, result] }, ctx);
assert.match(projected.messages[1].content[0].text, /pi-compaction tool=bash/);
const cacheStart = performance.now();
for (let iteration = 0; iteration < 1_000; iteration++) {
await h.handlers.get("context")![0]!({ messages: [assistant, result] }, ctx);
}
const cacheAverageMs = (performance.now() - cacheStart) / 1_000;
assert.ok(cacheAverageMs < 2, `full context handler cache-hit average ${cacheAverageMs.toFixed(3)}ms exceeded 2ms`);
assert.equal(h.entries.length, 1);
assert.equal(h.entries[0]!.customType, "pi-compaction");
const query = h.tools.get("context_tree_query");
const recovered = await query.execute("q", { toolCallIds: ["call-1"] }, new AbortController().signal, undefined, ctx);
assert.match(recovered.content[0].text, /failure evidence/);
await h.handlers.get("session_shutdown")![0]!({}, ctx);
});
test("model switching after a native checkpoint projects the portable checkpoint", async () => {
const h = harness();
piCompaction(h.pi);
const details = {
kind: "pi-compaction",
version: 1,
checkpoint: "native",
modelKey: "openai-codex/openai-codex-responses/gpt-old",
replacementHistory: [{ type: "compaction", encrypted_content: "opaque" }],
portableSummary: "decisions and files from the old model",
sourceFingerprint: "old",
};
const branch = [
{ type: "compaction", id: "c1", parentId: null, timestamp: new Date().toISOString(), summary: "marker", firstKeptEntryId: "m0", tokensBefore: 1_000, details },
{ type: "message", id: "m1", parentId: "c1", timestamp: new Date().toISOString(), message: { role: "user", content: "continue", timestamp: 20 } },
] as any[];
const ctx = context(branch);
await h.handlers.get("session_start")![0]!({}, ctx);
const projected = await h.handlers.get("context")![0]!({ messages: [] }, ctx);
assert.match(projected.messages[0].content[0].text, /decisions and files/);
assert.equal(projected.messages[1].content, "continue");
await h.handlers.get("session_shutdown")![0]!({}, ctx);
});
@@ -0,0 +1,50 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildReplacementHistory,
findNativeCheckpoint,
parseNativeCompactionDetails,
type NativeCompactionDetails,
} from "../src/native-compaction.ts";
const details: NativeCompactionDetails = {
kind: "pi-compaction",
version: 1,
checkpoint: "native",
modelKey: "openai-codex/openai-codex-responses/gpt-test",
replacementHistory: [{ type: "compaction", encrypted_content: "opaque" }],
portableSummary: "portable",
sourceFingerprint: "abc",
};
test("recognizes only the unified native checkpoint format", () => {
assert.deepEqual(parseNativeCompactionDetails(details), details);
assert.equal(parseNativeCompactionDetails({ ...details, kind: "openai-codex-native-compaction" }), undefined);
assert.equal(parseNativeCompactionDetails({ ...details, portableSummary: undefined }), undefined);
});
test("latest Pi compaction entry is the authoritative native checkpoint", () => {
const branch = [
{ type: "message", id: "m1" },
{ type: "compaction", id: "c1", details },
] as any;
const found = findNativeCheckpoint(branch);
assert.equal(found.status, "valid");
if (found.status === "valid") assert.equal(found.checkpoint.entryIndex, 1);
const shadowed = findNativeCheckpoint([...branch, { type: "compaction", id: "c2", details: { kind: "other" } }] as any);
assert.equal(shadowed.status, "none");
const portable = findNativeCheckpoint([
...branch,
{ type: "compaction", id: "c3", details: { kind: "pi-compaction", version: 1, checkpoint: "portable" } },
] as any);
assert.equal(portable.status, "none");
});
test("replacement history ends with exactly one native item", () => {
const history = buildReplacementHistory([
{ role: "user", content: [{ type: "input_text", text: "recent" }] },
], { type: "compaction", encrypted_content: "opaque" }, 1_000);
assert.equal(history.at(-1)?.type, "compaction");
assert.equal(history.filter((item) => item.type === "compaction").length, 1);
});
+29
View File
@@ -0,0 +1,29 @@
import test from "node:test";
import assert from "node:assert/strict";
import { performance } from "node:perf_hooks";
import { DEFAULT_CONFIG } from "../src/config.ts";
import { SegmentIndex } from "../src/segment-index.ts";
function message(index: number) {
return { role: "user", content: `message-${index}`, timestamp: index + 1 };
}
test("cached projection and append-only delta stay inside the hot-path budget", () => {
const index = new SegmentIndex("perf");
index.hydrate([], DEFAULT_CONFIG);
const messages = Array.from({ length: 10_000 }, (_, value) => message(value));
const params = { environmentHash: "env", environmentTokens: 2_000, contextWindow: 300_000 };
index.project(messages, params);
const cacheStart = performance.now();
for (let iteration = 0; iteration < 1_000; iteration++) index.project(messages, params);
const cacheAverageMs = (performance.now() - cacheStart) / 1_000;
const appended = [...messages, message(10_000)];
const deltaStart = performance.now();
index.project(appended, params);
const deltaMs = performance.now() - deltaStart;
assert.ok(cacheAverageMs < 2, `cache-hit average ${cacheAverageMs.toFixed(3)}ms exceeded 2ms`);
assert.ok(deltaMs < 10, `delta projection ${deltaMs.toFixed(3)}ms exceeded 10ms`);
});
+84
View File
@@ -0,0 +1,84 @@
import test from "node:test";
import assert from "node:assert/strict";
import { DEFAULT_CONFIG } from "../src/config.ts";
import { SegmentIndex, deterministicSummary } from "../src/segment-index.ts";
const assistant = {
role: "assistant",
content: [{ type: "toolCall", id: "call-1", name: "bash", arguments: { command: "npm test" } }],
timestamp: 10,
};
const result = {
role: "toolResult",
toolCallId: "call-1",
toolName: "bash",
content: [{ type: "text", text: `failure\n${"x".repeat(12_000)}\nstack tail` }],
isError: true,
timestamp: 11,
};
test("deterministic summary is bounded and preserves head/tail evidence", () => {
const summary = deterministicSummary({
toolName: "bash",
args: { command: "npm test" },
rawText: result.content[0]!.text,
isError: true,
maxStubTokens: 256,
});
assert.match(summary, /tool=bash status=error/);
assert.match(summary, /failure/);
assert.match(summary, /stack tail/);
assert.ok(summary.length <= 1_100);
});
test("projection incrementally replaces tool results and caches identical input", () => {
const index = new SegmentIndex("session-1");
index.hydrate([], DEFAULT_CONFIG);
const records = index.captureTurn(assistant, [result], DEFAULT_CONFIG);
assert.equal(records.length, 1);
const messages = [assistant, result];
const first = index.project(messages, {
environmentHash: "env",
environmentTokens: 100,
contextWindow: 300_000,
});
const second = index.project(messages, {
environmentHash: "env",
environmentTokens: 100,
contextWindow: 300_000,
});
assert.strictEqual(first, second);
assert.ok(first.effectiveTokens < first.rawTokens);
assert.match(first.messages[1]!.content[0].text, /context_tree_query/);
const record = records[0]!;
assert.ok(index.applySemantic(record.sourceKey, record.sourceHash, "- npm test failed\n- stack tail"));
const semantic = index.project(messages, {
environmentHash: "env",
environmentTokens: 100,
contextWindow: 300_000,
});
assert.notStrictEqual(semantic, first);
assert.match(semantic.messages[1]!.content[0].text, /npm test failed/);
});
test("usage-backed baseline is adjusted only by projected delta", () => {
const index = new SegmentIndex("session-2");
index.hydrate([], DEFAULT_CONFIG);
const initial = index.project([assistant], {
environmentHash: "env",
environmentTokens: 50,
contextWindow: 100_000,
});
index.recordUsage(1_000, initial.messageEstimate, "env");
const next = index.project([assistant, { role: "user", content: "next", timestamp: 12 }], {
environmentHash: "env",
environmentTokens: 50,
contextWindow: 100_000,
});
assert.equal(next.confidence, "usage-backed");
assert.ok(next.effectiveTokens >= 1_000);
assert.ok(next.effectiveTokens < 1_100);
});
@@ -0,0 +1,19 @@
import test from "node:test";
import assert from "node:assert/strict";
import { shouldCompact } from "../src/token-accounting.ts";
test("raw pressure cannot trigger compaction while effective lower-bound stays below 90%", () => {
const config = { hardRatio: 0.9 };
const snapshot = { lowerBound: 231_000, contextWindow: 300_000 };
assert.equal(shouldCompact("threshold", snapshot, config), false);
assert.equal(shouldCompact("manual", snapshot, config), true);
assert.equal(shouldCompact("overflow", snapshot, config), true);
});
test("effective lower-bound at the configured threshold compacts", () => {
assert.equal(shouldCompact(
"threshold",
{ lowerBound: 270_000, contextWindow: 300_000 },
{ hardRatio: 0.9 },
), true);
});
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2023",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"types": ["node"]
},
"include": ["index.ts", "src/**/*.ts", "test/**/*.ts"]
}