feat: add context engine transcript maintenance#51191
feat: add context engine transcript maintenance#51191jalehman merged 7 commits intoopenclaw:mainfrom
Conversation
🔒 Aisle Security AnalysisWe found 2 potential security issue(s) in this PR:
1. 🟠 Unbounded transcript rewrite in context-engine maintenance enables resource exhaustion (DoS) and bypasses size guards
Description
Vulnerable code (unbounded replay + bypass of guards): const appendMessage = getRawSessionAppendMessage(params.sessionManager);
for (let index = matchedIndices[0]; index < branch.length; index++) {
const entry = branch[index];
const replacement = entry.type === "message" ? replacementsById.get(entry.id) : undefined;
const newEntryId = replacement === undefined
? appendBranchEntry({ sessionManager: params.sessionManager, entry, rewrittenEntryIds, appendMessage })
: appendMessage(replacement as Parameters<typeof params.sessionManager.appendMessage>[0]);
rewrittenEntryIds.set(entry.id, newEntryId);
}Automatic invocation path (amplifies impact):
Impact:
RecommendationAdd runtime-enforced limits to prevent unbounded rewrite work and uncontrolled transcript growth. Possible mitigations (can be combined):
Example (sketch): const MAX_REPLACEMENTS = 25;
const MAX_SUFFIX_ENTRIES = 500;
const MAX_BYTES_GROWTH = 100_000; // 100KB
if (params.replacements.length > MAX_REPLACEMENTS) {
return { changed: false, bytesFreed: 0, rewrittenEntries: 0, reason: "too many replacements" };
}
// compute matchedIndices + bytesDelta
let bytesGrowth = 0;
bytesGrowth += Math.max(0, replacementBytes - originalBytes);
if (bytesGrowth > MAX_BYTES_GROWTH) {
return { changed: false, bytesFreed: 0, rewrittenEntries: 0, reason: "rewrite would grow transcript too much" };
}
const suffixLen = branch.length - matchedIndices[0];
if (suffixLen > MAX_SUFFIX_ENTRIES) {
return { changed: false, bytesFreed: 0, rewrittenEntries: 0, reason: "rewrite suffix too large" };
}
// Apply safety caps to replacement messages (e.g. toolResult truncation)
const safeAppend = params.sessionManager.appendMessage.bind(params.sessionManager);Also consider rate limiting maintenance rewrites (e.g., run at most once per N turns or only when 2. 🟡 Context-engine plugins can tamper with persisted session transcripts via runtimeContext.rewriteTranscriptEntries()
DescriptionThe runtime now passes a Security impact:
Vulnerable code (capability exposure): return {
...params.runtimeContext,
rewriteTranscriptEntries: async (request) => {
if (params.sessionManager) {
return rewriteTranscriptEntriesInSessionManager({
sessionManager: params.sessionManager,
replacements: request.replacements,
});
}
return await rewriteTranscriptEntriesInSessionFile({ ... });
},
};Because RecommendationTreat transcript rewriting as a privileged operation. Suggested mitigations (pick a combination appropriate for your threat model):
Example (runtime validation sketch): rewriteTranscriptEntries: async (request) => {
for (const r of request.replacements) {
if (r.message.role !== "toolResult") {
throw new Error("transcript rewrite only allowed for toolResult entries");
}
}
// Optionally: run the same toolResult truncation/sanitizers here.
return rewriteTranscriptEntriesInSessionFile({ ... });
}Analyzed PR: #51191 at commit Last updated on: 2026-03-21T00:09:32Z |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2ae1c6406d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Greptile SummaryThis PR adds a well-designed Key changes:
Items to address:
Confidence Score: 4/5
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8422dc0d08
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
8422dc0 to
90539dc
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 207e0553fb
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
207e055 to
9661aab
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9661aab295
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| rewriteTranscriptEntries: async (request) => { | ||
| if (params.sessionManager) { | ||
| return rewriteTranscriptEntriesInSessionManager({ | ||
| sessionManager: params.sessionManager, | ||
| replacements: request.replacements, | ||
| }); |
There was a problem hiding this comment.
Emit transcript updates for in-process maintenance rewrites
When maintain() uses the sessionManager helper, this branch-and-reappend path mutates the active transcript without ever calling emitSessionTranscriptUpdate(). I checked src/gateway/sessions-history-http.ts:229-269 and src/gateway/server.impl.ts:850-855; both live subscribers only refresh from onSessionTranscriptUpdate, so bootstrap/post-turn maintenance rewrites stay invisible until some unrelated message append happens. In practice, connected history viewers can keep serving the pre-maintenance tool result even though the JSONL has already been rewritten.
Useful? React with 👍 / 👎.
| if (result.changed) { | ||
| emitSessionTranscriptUpdate(params.sessionFile); |
There was a problem hiding this comment.
Include a websocket-visible invalidation for file-based rewrites
The file-based maintenance path emits only emitSessionTranscriptUpdate(params.sessionFile). I checked src/gateway/server.impl.ts:850-855, and that listener immediately returns when update.message is missing, so overflow/manual-compaction maintenance rewrites never generate any websocket refresh for already-connected clients. Those sessions will continue showing the pre-maintenance transcript until a later append or reconnect, even though compaction has already rewritten the JSONL on disk.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: be4e4c461d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (engineOwnsCompaction && result.ok && result.compacted) { | ||
| await runPostCompactionSideEffects({ | ||
| config: params.config, |
There was a problem hiding this comment.
Move maintenance ahead of runtime compaction side effects
Because runContextEngineMaintenance() now runs immediately before this branch, only ownsCompaction engines get runPostCompactionSideEffects() after a maintenance rewrite. For legacy/delegated engines, contextEngine.compact() has already gone through compactEmbeddedPiSessionDirect() and fired the post-compaction sync/hook path before returning (see src/agents/pi-embedded-runner/compact.ts:1001-1087), so a maintain() rewrite here leaves session-memory indexing and after_compaction consumers pointed at the pre-maintenance transcript. The same stale-ordering also exists in the overflow-recovery path in src/agents/pi-embedded-runner/run.ts:1180-1188.
Useful? React with 👍 / 👎.
be4e4c4 to
59c9ce6
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 59c9ce696e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (rewriteResult.changed) { | ||
| emitSessionTranscriptUpdate(sessionFile); | ||
| } |
There was a problem hiding this comment.
Publish a websocket-visible invalidation after truncation
When overflow recovery truncates oversized tool results, this emits only { sessionFile }. I checked src/gateway/server.impl.ts:850-855, and websocket subscribers ignore transcript updates whose message is missing, so already-connected clients never refresh to the shortened transcript until some later append or reconnect. In practice the retry can succeed while the control UI still shows the pre-truncation tool result that caused the overflow.
Useful? React with 👍 / 👎.
Regeneration-Prompt: | Address review feedback on the context-engine transcript maintenance PR. Preserve active-branch labels and other branch-relative metadata when transcript rewrites branch-and-reappend a suffix, because bookmarks on messages after the first rewritten entry were disappearing. Keep bootstrap and post-turn maintenance aligned with the live SessionManager so rewrites are visible immediately on the same turn. Only run maintenance after the engine's afterTurn/ingest finalization succeeds, and invoke maintenance on overflow-recovery compactions too so summarized tool-output rewrites can happen on the retry path. Add focused regression tests for rewrite label preservation, maintenance runtime wiring, and overflow compaction hooks. Note that the existing attempt.spawn-workspace wrapper test hangs in this environment, so verification should rely on the targeted passing tests and the clean build unless that harness issue is separately debugged.
Regeneration-Prompt: | Follow up on remaining Codex review comments for the context-engine transcript maintenance PR. When transcript rewrites replay a suffix that includes compaction entries, remap compaction keep markers to the rewritten entry ids so SessionManager still preserves the intended kept messages on the next load. Also run startup maintenance for existing sessions even when a context engine implements maintain() without bootstrap(), so transcript GC or externalization can happen before the first prompt. Add focused regression tests for compaction marker remapping and the startup-maintenance entry path. The broader attempt.spawn-workspace harness is still flaky in this environment, so rely on the direct unit coverage plus build verification.
Regeneration-Prompt: | Rebase the context engine transcript maintenance PR onto the latest origin/main, then address the remaining valid review findings in transcript maintenance. The file-based rewrite path must take the normal session write lock before mutating a JSONL transcript so compaction or other runners cannot race it. The in-memory rewrite path must also preserve the exact replacement history requested by the context engine instead of re-running session persistence hooks, tool-result transforms, or truncation while replaying rewritten entries through a guarded SessionManager. Keep the fix narrow to the rewrite machinery and add focused tests that prove lock acquisition and hook bypass behavior.
Regeneration-Prompt: | Fix the CI lint failure introduced on the context engine transcript maintenance PR. oxlint --type-aware flagged a redundant type assertion in src/agents/pi-embedded-runner/compact.hooks.test.ts where runtimeContext was already inferred as Record<string, unknown> | undefined. Remove only the unnecessary trailing cast, keep the test behavior unchanged, and verify that pnpm lint is green again. Do not broaden scope into unrelated failing CI jobs whose logs point at plugin-boundary baselines or extension/channel tests outside this PR's diff.
Take the session write lock inside truncateOversizedToolResultsInSession() so overflow recovery does not rewrite the JSONL transcript concurrently with other writers. Emit the normal transcript update event after a successful rewrite and cover the path with a focused unit test. Regeneration-Prompt: | Aisle flagged three security concerns on the transcript-maintenance PR. Review the current code and only fix the one that meaningfully improves the implementation without changing the plugin trust model or the intended lossless rewrite semantics. The worthwhile fix is in the overflow-recovery tool-result truncation path: it was still opening and rewriting the session transcript without taking the session write lock, even though the shared file-backed rewrite helper had already been hardened. Update truncateOversizedToolResultsInSession() so the helper itself acquires and releases acquireSessionWriteLock around the open plus rewrite sequence, and emit the normal transcript update event when a rewrite actually occurs. Add a focused unit test that mocks the lock helper, exercises the session truncation path, and verifies lock acquisition, release, and transcript-update emission while still confirming the oversized tool result is truncated.
59c9ce6 to
b42a3c2
Compare
|
Merged via squash.
Thanks @jalehman! |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b42a3c28b4
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if (!firstMatchedEntry.parentId) { | ||
| params.sessionManager.resetLeaf(); | ||
| } else { | ||
| params.sessionManager.branch(firstMatchedEntry.parentId); | ||
| } | ||
|
|
||
| // Maintenance rewrites should preserve the exact requested history without | ||
| // re-running persistence hooks or size truncation on replayed messages. | ||
| const appendMessage = getRawSessionAppendMessage(params.sessionManager); | ||
| const rewrittenEntryIds = new Map<string, string>(); | ||
| for (let index = matchedIndices[0]; index < branch.length; index++) { | ||
| const entry = branch[index]; | ||
| const replacement = entry.type === "message" ? replacementsById.get(entry.id) : undefined; | ||
| const newEntryId = | ||
| replacement === undefined | ||
| ? appendBranchEntry({ | ||
| sessionManager: params.sessionManager, | ||
| entry, | ||
| rewrittenEntryIds, | ||
| appendMessage, | ||
| }) | ||
| : appendMessage(replacement as Parameters<typeof params.sessionManager.appendMessage>[0]); | ||
| rewrittenEntryIds.set(entry.id, newEntryId); |
There was a problem hiding this comment.
Rewrite the JSONL instead of appending a new branch
For the new maintain() use case, this path does not actually reclaim transcript bytes on disk: it calls branch()/resetLeaf() and then appends a fresh suffix with new entry IDs, but there is no file rewrite that removes the original oversized entries. In the common case of externalizing a large tool result, the active branch becomes smaller while the JSONL keeps the old 100MB payload plus the new replacement branch, so session files keep growing (and can grow faster) instead of solving the disk-bloat problem this feature is meant to address.
Useful? React with 👍 / 👎.
Merged via squash. Prepared head SHA: b42a3c2 Co-authored-by: jalehman <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
Merged via squash. Prepared head SHA: b42a3c2 Co-authored-by: jalehman <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
Merged via squash. Prepared head SHA: b42a3c2 Co-authored-by: jalehman <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
Merged via squash. Prepared head SHA: b42a3c2 Co-authored-by: jalehman <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
Merged via squash. Prepared head SHA: b42a3c2 Co-authored-by: jalehman <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman (cherry picked from commit 751d5b7)
Summary
Adds an optional
maintain()lifecycle hook to theContextEngineinterface, enabling context engine plugins to request safe transcript rewrites without coupling to Pi session internals.Problem
Context engines like lossless-claw keep the context window bounded via DAG-based summarization, but the underlying JSONL transcript grows unbounded. Large tool results that have already been summarized and externalized in the context engine still occupy full space in the transcript file, leading to multi-hundred-MB session files in production.
Solution
New
ContextEngine.maintain()hook — called after bootstrap, after successful turns, and after successful compaction. Engines decide what is safe to rewrite; the runtime owns how the session DAG is updated on disk.New
TranscriptRewriteRequest/Resulttypes — engines nominate transcript entries for replacement viaruntimeContext.rewriteTranscriptEntries(). The runtime performs a branch-and-reappend pass on the JSONL, preserving all non-message entry types.Refactored
tool-result-truncation.ts— the existing inline branch-and-reappend logic was extracted into a generictranscript-rewrite.tsmodule, and tool-result truncation now uses it. This eliminates ~70 lines of duplication.Plugin SDK exports — new types are exported so external plugins can implement
maintain().Changes
src/context-engine/types.ts—maintain()method,TranscriptRewriteRequest,TranscriptRewriteResult,ContextEngineMaintenanceResulttypes, extendedContextEngineRuntimeContextsrc/agents/pi-embedded-runner/transcript-rewrite.ts— generic branch-and-reappend rewriter (202 lines)src/agents/pi-embedded-runner/context-engine-maintenance.ts— bridge that attaches runtime rewriter and callsmaintain()(70 lines)src/agents/pi-embedded-runner/run/attempt.ts— wired at bootstrap and post-turn call sitessrc/agents/pi-embedded-runner/compact.ts— wired at post-compaction call sitesrc/agents/pi-embedded-runner/tool-result-truncation.ts— refactored to use generic rewriter (-71 lines)src/context-engine/index.ts,src/context-engine/registry.ts— re-exportssrc/plugin-sdk/index.ts— SDK exports for plugin authorsTests
transcript-rewrite.test.ts— 167 lines covering empty sessions, no-match, single/multiple replacements, non-message entry preservationcontext-engine-maintenance.test.ts— 106 lines covering missing engine, no maintain method, successful maintenance, error handlingcompact.hooks.test.ts— 30 lines covering post-compaction maintenance triggercontext-engine.test.ts— 35 lines covering type/interface contractsRelated