|
| 1 | +import { createHash } from "node:crypto"; |
1 | 2 | import type { AgentMessage } from "@mariozechner/pi-agent-core"; |
2 | 3 | import { SessionManager } from "@mariozechner/pi-coding-agent"; |
3 | 4 | import type { |
@@ -27,6 +28,40 @@ function estimateMessageBytes(message: AgentMessage): number { |
27 | 28 | return Buffer.byteLength(JSON.stringify(message), "utf8"); |
28 | 29 | } |
29 | 30 |
|
| 31 | +function hashJson(value: unknown): string { |
| 32 | + return createHash("sha256") |
| 33 | + .update(JSON.stringify(value ?? null)) |
| 34 | + .digest("hex"); |
| 35 | +} |
| 36 | + |
| 37 | +function computeBranchEntryReplayIdentity(entry: SessionBranchEntry): string | null { |
| 38 | + if (entry.type === "message") { |
| 39 | + return `message:${hashJson(entry.message)}`; |
| 40 | + } |
| 41 | + if (entry.type === "compaction") { |
| 42 | + return `compaction:${entry.tokensBefore}:${hashJson({ |
| 43 | + summary: entry.summary, |
| 44 | + details: entry.details, |
| 45 | + fromHook: entry.fromHook, |
| 46 | + })}`; |
| 47 | + } |
| 48 | + if (entry.type === "custom") { |
| 49 | + return `custom:${entry.customType}:${hashJson(entry.data)}`; |
| 50 | + } |
| 51 | + if (entry.type === "custom_message") { |
| 52 | + return `custom_message:${entry.customType}:${hashJson({ |
| 53 | + content: entry.content, |
| 54 | + details: entry.details, |
| 55 | + display: entry.display, |
| 56 | + })}`; |
| 57 | + } |
| 58 | + return null; |
| 59 | +} |
| 60 | + |
| 61 | +function computeMessageReplayIdentity(message: AgentMessage): string { |
| 62 | + return `message:${hashJson(message)}`; |
| 63 | +} |
| 64 | + |
30 | 65 | function remapEntryId( |
31 | 66 | entryId: string | null | undefined, |
32 | 67 | rewrittenEntryIds: ReadonlyMap<string, string>, |
@@ -227,19 +262,35 @@ export function rewriteTranscriptEntriesInSessionManager(params: { |
227 | 262 | // re-running persistence hooks or size truncation on replayed messages. |
228 | 263 | const appendMessage = getRawSessionAppendMessage(params.sessionManager); |
229 | 264 | const rewrittenEntryIds = new Map<string, string>(); |
| 265 | + const emittedReplayIdentities = new Map<string, string>(); |
230 | 266 | for (let index = matchedIndices[0]; index < branch.length; index++) { |
231 | 267 | const entry = branch[index]; |
232 | 268 | const replacement = entry.type === "message" ? replacementsById.get(entry.id) : undefined; |
233 | | - const newEntryId = |
234 | | - replacement === undefined |
235 | | - ? appendBranchEntry({ |
236 | | - sessionManager: params.sessionManager, |
237 | | - entry, |
238 | | - rewrittenEntryIds, |
239 | | - appendMessage, |
240 | | - }) |
241 | | - : appendMessage(replacement as Parameters<typeof params.sessionManager.appendMessage>[0]); |
| 269 | + if (replacement === undefined) { |
| 270 | + const replayIdentity = computeBranchEntryReplayIdentity(entry); |
| 271 | + const existingEntryId = |
| 272 | + replayIdentity === null ? undefined : emittedReplayIdentities.get(replayIdentity); |
| 273 | + if (existingEntryId !== undefined) { |
| 274 | + rewrittenEntryIds.set(entry.id, existingEntryId); |
| 275 | + continue; |
| 276 | + } |
| 277 | + const newEntryId = appendBranchEntry({ |
| 278 | + sessionManager: params.sessionManager, |
| 279 | + entry, |
| 280 | + rewrittenEntryIds, |
| 281 | + appendMessage, |
| 282 | + }); |
| 283 | + rewrittenEntryIds.set(entry.id, newEntryId); |
| 284 | + if (replayIdentity !== null) { |
| 285 | + emittedReplayIdentities.set(replayIdentity, newEntryId); |
| 286 | + } |
| 287 | + continue; |
| 288 | + } |
| 289 | + const newEntryId = appendMessage( |
| 290 | + replacement as Parameters<typeof params.sessionManager.appendMessage>[0], |
| 291 | + ); |
242 | 292 | rewrittenEntryIds.set(entry.id, newEntryId); |
| 293 | + emittedReplayIdentities.set(computeMessageReplayIdentity(replacement), newEntryId); |
243 | 294 | } |
244 | 295 |
|
245 | 296 | return { |
@@ -328,19 +379,34 @@ export function rewriteTranscriptEntriesInState(params: { |
328 | 379 |
|
329 | 380 | const appendedEntries: SessionBranchEntry[] = []; |
330 | 381 | const rewrittenEntryIds = new Map<string, string>(); |
| 382 | + const emittedReplayIdentities = new Map<string, string>(); |
331 | 383 | for (let index = matchedIndices[0]; index < branch.length; index++) { |
332 | 384 | const entry = branch[index]; |
333 | 385 | const replacement = entry.type === "message" ? replacementsById.get(entry.id) : undefined; |
334 | | - const newEntry = |
335 | | - replacement === undefined |
336 | | - ? appendTranscriptStateBranchEntry({ |
337 | | - state: params.state, |
338 | | - entry, |
339 | | - rewrittenEntryIds, |
340 | | - }) |
341 | | - : params.state.appendMessage(replacement); |
| 386 | + if (replacement === undefined) { |
| 387 | + const replayIdentity = computeBranchEntryReplayIdentity(entry); |
| 388 | + const existingEntryId = |
| 389 | + replayIdentity === null ? undefined : emittedReplayIdentities.get(replayIdentity); |
| 390 | + if (existingEntryId !== undefined) { |
| 391 | + rewrittenEntryIds.set(entry.id, existingEntryId); |
| 392 | + continue; |
| 393 | + } |
| 394 | + const newEntry = appendTranscriptStateBranchEntry({ |
| 395 | + state: params.state, |
| 396 | + entry, |
| 397 | + rewrittenEntryIds, |
| 398 | + }); |
| 399 | + rewrittenEntryIds.set(entry.id, newEntry.id); |
| 400 | + appendedEntries.push(newEntry); |
| 401 | + if (replayIdentity !== null) { |
| 402 | + emittedReplayIdentities.set(replayIdentity, newEntry.id); |
| 403 | + } |
| 404 | + continue; |
| 405 | + } |
| 406 | + const newEntry = params.state.appendMessage(replacement); |
342 | 407 | rewrittenEntryIds.set(entry.id, newEntry.id); |
343 | 408 | appendedEntries.push(newEntry); |
| 409 | + emittedReplayIdentities.set(computeMessageReplayIdentity(replacement), newEntry.id); |
344 | 410 | } |
345 | 411 |
|
346 | 412 | return { |
|
0 commit comments