mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
feat: unify context compaction
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`);
|
||||
},
|
||||
});
|
||||
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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`);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user