Skip to content

feat(core): add pi event adapter for 0.41 ingest - #1546

Open
ZeR020 wants to merge 3 commits into
kunickiaj:mainfrom
ZeR020:stack/2-pi-hooks-adapter
Open

feat(core): add pi event adapter for 0.41 ingest#1546
ZeR020 wants to merge 3 commits into
kunickiaj:mainfrom
ZeR020:stack/2-pi-hooks-adapter

Conversation

@ZeR020

@ZeR020 ZeR020 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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 ingestRawEvents already accepts, with source: "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-only
  • Envelope fields include session_stream_id / session_id aliases required by 0.41 ingest
  • Test: ingestRawEvents persists source pi and creates zero opencode rows

Test plan

  • vitest run packages/core/src/pi-hooks.test.ts — 40/40 passed
  • Gates lint, tsc, build green locally; test passes 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

Map pi extension events to AdapterEvent envelopes with source "pi"
and deterministic ids so later stacked routes can call ingestRawEvents
without a parallel writer.
Copilot AI lite review requested due to automatic review settings August 29, 2026 07:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +187 to +190
const role = coerceString(field(payload, "role")).toLowerCase();
const text = coerceString(field(payload, "text", "content", "prompt"));
if (!text) return null;
if (!entryId) return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 kunickiaj left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two ingestion-identity issues need correction before this adapter is safe to merge.

Comment thread packages/core/src/pi-hooks.ts Outdated
*/
function buildPiEventId(sessionId: string, stablePart: string): string {
const part = stablePart.trim();
if (!part) throw new Error("pi event id stable part is required");

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

05d7b2f

Comment thread packages/core/src/pi-hooks.ts Outdated
const reason = field(payload, "reason");
eventType = "session_end";
eventPayload = { reason: reason ?? null };
idPart = entryId || "session_end";

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

05d7b2f

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.
@ZeR020

ZeR020 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

@kunickiaj requested identity fixes are in 05d7b2f7 (hashed event IDs + shutdown discriminator). CI is green. Ready for re-review.

@ZeR020

ZeR020 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

@kunickiaj ready for your review whenever you have a moment.

@kunickiaj kunickiaj left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)}`;

@kunickiaj kunickiaj Sep 2, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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> = {

@kunickiaj kunickiaj Sep 2, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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", () => {

@kunickiaj kunickiaj Sep 2, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This covers reload → quit, whose reasons already differ. Please add reload → reload: both occurrences should insert, while retrying either payload should skip.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants