feat(core): add pi event adapter for 0.41 ingest - #1546
Conversation
Map pi extension events to AdapterEvent envelopes with source "pi" and deterministic ids so later stacked routes can call ingestRawEvents without a parallel writer.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4e8d1941eb
ℹ️ About Codex in GitHub
Your team has set up Codex to 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 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const role = coerceString(field(payload, "role")).toLowerCase(); | ||
| const text = coerceString(field(payload, "text", "content", "prompt")); | ||
| if (!text) return null; | ||
| if (!entryId) return null; |
There was a problem hiding this comment.
Read
message_end fields from event.message
When this adapter receives Pi 0.41's native message_end event, the role, content, and message id are nested under event.message, not provided as top-level role, text, and entryId fields. This branch therefore sees an empty role and returns null for every real user and assistant message, so prompts and assistant transcript content never reach the raw-event sweeper. Unwrap the nested message (including text content/blocks and its id) before applying this mapping.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Correct about Pi 0.41's native message_end shape — but this adapter intentionally never sees the raw pi event. The pi extension's pi.on("message_end") handler unwraps event.message (role/text via extractMessageRole/extractMessageText) and synthesizes a deterministic entryId (stableMessageEntryId, timestamp-discriminated) before POSTing the flat payload this mapper consumes (packages/pi-extension/src/index.ts, payloads.ts — landing in a later layer of this stack, see #1430). Keeping the unwrap on the producer side is what lets core stay pi-agnostic. Traced end-to-end: no caller passes the raw nested event. No change needed here.
kunickiaj
left a comment
There was a problem hiding this comment.
Two ingestion-identity issues need correction before this adapter is safe to merge.
| */ | ||
| function buildPiEventId(sessionId: string, stablePart: string): string { | ||
| const part = stablePart.trim(); | ||
| if (!part) throw new Error("pi event id stable part is required"); |
There was a problem hiding this comment.
Raw ingestion validates event_id against /^[A-Za-z0-9._:-]+$/ and a 128-character limit, but this concatenates unconstrained session and tool/message IDs. For example, sessionId="session/with/slash" and entryId="entry+1" produces an ID that ingestRawEvents rejects with RawEventIngestValidationError. Pi toolCallId is typed only as string, so provider-specific IDs can trigger this even if session IDs are UUIDs. Please hash the identity components into a fixed-length safe ID, as the Claude and Codex adapters do, and add charset/length regression coverage.
There was a problem hiding this comment.
Hashed identity into pi_evt_ + sha256(sessionId|piEvent|idPart).hex.slice(0, 24), matching Claude/Codex. Added charset/length coverage, including session/with/slash + entry+1.
| const reason = field(payload, "reason"); | ||
| eventType = "session_end"; | ||
| eventPayload = { reason: reason ?? null }; | ||
| idPart = entryId || "session_end"; |
There was a problem hiding this comment.
This fallback gives every shutdown for a session the same event ID. Pi can shut an extension down for reload and later shut the same session down for exit; the second event is then deduplicated, preserving the earlier reason/timestamp and losing the final lifecycle event. The continuation stack's payload builder also currently supplies the fixed entryId "session_end", so it hits the same collision. Please require or derive a retry-stable discriminator for each distinct shutdown occurrence and add a reload-then-exit regression test.
There was a problem hiding this comment.
Shutdown no longer uses a fixed session_end id. When entryId is missing or that generic value, the discriminator is session_end:<reason>. Reload then quit persist as two events; identical retries still dedupe.
Raw ingest rejects concatenated session/entry ids that contain slash or plus. Hash identity components like Claude/Codex. session_shutdown fell back to a fixed session_end id, so reload then quit collapsed to one row. Derive idPart from reason when entryId is missing or the generic session_end.
|
@kunickiaj requested identity fixes are in |
|
@kunickiaj ready for your review whenever you have a moment. |
kunickiaj
left a comment
There was a problem hiding this comment.
Thanks for addressing the original review. The hashed-ID fix looks good. One shutdown edge case still blocks approval; the other two comments are small identity-contract follow-ups that should fit naturally with that change.
| const reason = field(payload, "reason"); | ||
| eventType = "session_end"; | ||
| eventPayload = { reason: reason ?? null }; | ||
| idPart = entryId && entryId !== "session_end" ? entryId : `session_end:${coerceString(reason)}`; |
There was a problem hiding this comment.
Two shutdowns with the same reason (for example, /reload twice) still produce the same ID, so the second is dropped. Please have the producer send a stable per-occurrence entryId—or use its stable payload timestamp as the fallback—and add reload → reload coverage.
|
|
||
| if (!idPart) return null; | ||
|
|
||
| const meta: Record<string, unknown> = { |
There was a problem hiding this comment.
Please match the Claude and Codex adapters by exporting PI_EVENT_ID_ALGO = "pi/1" and storing it as meta.event_id_algo. This keeps stored IDs attributable if the algorithm changes.
| expect(result.skipped).toBe(0); | ||
| }); | ||
|
|
||
| it("persists reload then exit as distinct session_end events", () => { |
There was a problem hiding this comment.
This covers reload → quit, whose reasons already differ. Please add reload → reload: both occurrences should insert, while retrying either payload should skip.
Second PR in the pi integration stack — context and overall design in #1430. Builds on #1473 (merged); the diff here is the pi adapter only.
Why
Pi events need a core adapter that produces the same envelope shape
ingestRawEventsalready accepts, withsource: "pi"explicit and deterministic ids so HTTP/CLI retries do not duplicate rows.What
packages/core/src/pi-hooks.ts: map session/prompt/tool events; compaction is flush-onlysession_stream_id/session_idaliases required by 0.41 ingestingestRawEventspersists sourcepiand creates zero opencode rowsTest plan
vitest run packages/core/src/pi-hooks.test.ts— 40/40 passedlint,tsc,buildgreen locally;testpasses except local-sandbox env failures unrelated to this change (sqlite-vec native ext unavailable in this dev sandbox)@kunickiaj layer 2 of the stack is ready for review whenever you have a moment. Layers 3–8 will follow one at a time as each predecessor merges, same as this one.
Related: #1430, #1429, #1473