@tinoy/pi-child-request-dump 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +28 -0
- package/child-request-dump.ts +281 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tinoy Thomas
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# @tinoy/pi-child-request-dump
|
|
2
|
+
|
|
3
|
+
Structure-only JSONL record of each outbound provider request in a child session.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pi install npm:@tinoy/pi-child-request-dump
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## What it needs at call time
|
|
10
|
+
|
|
11
|
+
A child session (a launch marker) and a writable state directory.
|
|
12
|
+
|
|
13
|
+
## Registers
|
|
14
|
+
|
|
15
|
+
no tool
|
|
16
|
+
|
|
17
|
+
## Caveats
|
|
18
|
+
|
|
19
|
+
No caveat rows declared: this unit has no soft dependency on another package in this repository. What it needs beyond its declared dependencies is machine capability, named below, and it refuses by name rather than failing to load.
|
|
20
|
+
|
|
21
|
+
## Dependencies
|
|
22
|
+
|
|
23
|
+
pi-supplied imports (`@earendil-works/pi-coding-agent` or "none") are peer dependencies with a `*` range and are
|
|
24
|
+
never bundled. This package has no npm dependencies.
|
|
25
|
+
|
|
26
|
+
## Licence
|
|
27
|
+
|
|
28
|
+
MIT — see the repository [LICENSE](../../LICENSE).
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* child-request-dump — structure-only record of every outbound provider request
|
|
3
|
+
* in a CHILD session.
|
|
4
|
+
*
|
|
5
|
+
* A provider that validates tool-call pairing rejects a request when a message
|
|
6
|
+
* with role "tool" answers a call that no preceding assistant message declares.
|
|
7
|
+
* That condition is a property of the message sequence alone, so it is recordable
|
|
8
|
+
* without any message content: the roles in order, the tool-call ids each
|
|
9
|
+
* assistant message declares, and the id each result answers. One JSONL line per
|
|
10
|
+
* request is written, so the last line of a run is the last request it built.
|
|
11
|
+
*
|
|
12
|
+
* Observation point. The row is built from the payload of this handler's turn.
|
|
13
|
+
* Handlers run in extension order, and `orphan-repair.ts` rewrites
|
|
14
|
+
* `payload.messages` later in that order, so a row here is the sequence pi's
|
|
15
|
+
* converter produced, before any repair — which is the sequence that reaches the
|
|
16
|
+
* provider when no repair is loaded.
|
|
17
|
+
*
|
|
18
|
+
* Content is NEVER recorded: no prompt text, no message text, no tool names, no
|
|
19
|
+
* tool arguments. Roles, counts and opaque tool-call ids only.
|
|
20
|
+
*
|
|
21
|
+
* Scope. Nothing is registered unless the process is a child: `PI_SUBAGENT_CHILD=1`
|
|
22
|
+
* for a session hosted by the async runner, `PI_SUBAGENT=1` for a launch through
|
|
23
|
+
* the `pi-subagent` wrapper. Ambient loading into a parent session records
|
|
24
|
+
* nothing. Children load this file through the settings route
|
|
25
|
+
* `subagents.defaultExtensions`.
|
|
26
|
+
*
|
|
27
|
+
* Log: $XDG_STATE_HOME/pi/child-request-dump.jsonl (default
|
|
28
|
+
* ~/.local/state/pi/child-request-dump.jsonl), capped at 256 KiB with one
|
|
29
|
+
* rotated sibling (child-request-dump.1.jsonl). Override with
|
|
30
|
+
* PI_CHILD_REQUEST_DUMP.
|
|
31
|
+
*
|
|
32
|
+
* Every fault is swallowed: a recorder must never break a provider request, and
|
|
33
|
+
* the append is asynchronous so no request waits on the file.
|
|
34
|
+
*/
|
|
35
|
+
import { appendFile, mkdirSync, rename, statSync } from "node:fs";
|
|
36
|
+
import { homedir } from "node:os";
|
|
37
|
+
import { dirname, join } from "node:path";
|
|
38
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
39
|
+
|
|
40
|
+
const CAP_BYTES = 256 * 1024;
|
|
41
|
+
|
|
42
|
+
function logPath(): string {
|
|
43
|
+
if (process.env.PI_CHILD_REQUEST_DUMP) return process.env.PI_CHILD_REQUEST_DUMP;
|
|
44
|
+
const state = process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
|
|
45
|
+
return join(state, "pi", "child-request-dump.jsonl");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The wire shapes differ by provider, so both spellings are read. */
|
|
49
|
+
interface WireMessage {
|
|
50
|
+
role?: unknown;
|
|
51
|
+
tool_call_id?: unknown;
|
|
52
|
+
tool_calls?: unknown;
|
|
53
|
+
toolCalls?: unknown;
|
|
54
|
+
content?: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The tool-call ids an assistant message declares. */
|
|
58
|
+
function declaredIds(m: WireMessage): string[] {
|
|
59
|
+
const ids: string[] = [];
|
|
60
|
+
const push = (v: unknown): void => {
|
|
61
|
+
if (typeof v === "string" && v !== "") ids.push(v);
|
|
62
|
+
};
|
|
63
|
+
if (Array.isArray(m.tool_calls)) {
|
|
64
|
+
for (const tc of m.tool_calls) push((tc as { id?: unknown })?.id);
|
|
65
|
+
}
|
|
66
|
+
if (Array.isArray(m.toolCalls)) {
|
|
67
|
+
for (const tc of m.toolCalls) push((tc as { id?: unknown })?.id);
|
|
68
|
+
}
|
|
69
|
+
if (Array.isArray(m.content)) {
|
|
70
|
+
for (const part of m.content as Array<{
|
|
71
|
+
type?: unknown;
|
|
72
|
+
id?: unknown;
|
|
73
|
+
toolCallId?: unknown;
|
|
74
|
+
tool_use_id?: unknown;
|
|
75
|
+
}>) {
|
|
76
|
+
if (part?.type === "toolCall" || part?.type === "tool_use" || part?.type === "function") {
|
|
77
|
+
push(part.id ?? part.toolCallId ?? part.tool_use_id);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return ids;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The tool-call id a result message answers, null when it carries none. */
|
|
85
|
+
function answeredId(m: WireMessage): string | null {
|
|
86
|
+
if (typeof m.tool_call_id === "string") return m.tool_call_id;
|
|
87
|
+
if (Array.isArray(m.content)) {
|
|
88
|
+
for (const part of m.content as Array<{
|
|
89
|
+
type?: unknown;
|
|
90
|
+
toolCallId?: unknown;
|
|
91
|
+
tool_use_id?: unknown;
|
|
92
|
+
}>) {
|
|
93
|
+
if (part?.type === "toolResult" || part?.type === "tool_result") {
|
|
94
|
+
const id = part.toolCallId ?? part.tool_use_id;
|
|
95
|
+
if (typeof id === "string") return id;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One row entry per message that carries a call or a result. */
|
|
103
|
+
interface Entry {
|
|
104
|
+
i: number;
|
|
105
|
+
r: string;
|
|
106
|
+
/** assistant: how many calls it declares */
|
|
107
|
+
tc?: number;
|
|
108
|
+
/** assistant: the ids it declares */
|
|
109
|
+
ids?: string[];
|
|
110
|
+
/** assistant: indices of the results that answer it */
|
|
111
|
+
paired?: number[];
|
|
112
|
+
/** tool: the id it answers, null when it carries none */
|
|
113
|
+
id?: string | null;
|
|
114
|
+
/** tool: index of the assistant message it follows */
|
|
115
|
+
after?: number;
|
|
116
|
+
/** tool: whether the assistant message it follows declares the answered id */
|
|
117
|
+
ok?: boolean;
|
|
118
|
+
/** tool: whether any preceding assistant message declares the answered id */
|
|
119
|
+
prev?: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface Shape {
|
|
123
|
+
roles: string[];
|
|
124
|
+
entries: Entry[];
|
|
125
|
+
orphans: Array<{ i: number; id: string | null }>;
|
|
126
|
+
late: Array<{ i: number; id: string | null }>;
|
|
127
|
+
calls: number;
|
|
128
|
+
results: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A result is paired when the assistant message it follows declares the id it
|
|
133
|
+
* answers. The provider's rule is weaker — a result is valid when ANY preceding
|
|
134
|
+
* assistant message declares the id — so two conditions are recorded apart:
|
|
135
|
+
* `orphans` holds results no preceding assistant declares, which is the sequence
|
|
136
|
+
* the provider rejects, and `late` holds results whose call belongs to an earlier
|
|
137
|
+
* assistant than the one they follow, which is an order anomaly the provider
|
|
138
|
+
* still accepts. `after` names the assistant a result follows, so a broken pair
|
|
139
|
+
* also names the message that lost its calls.
|
|
140
|
+
*/
|
|
141
|
+
function scanShape(messages: unknown[]): Shape {
|
|
142
|
+
const roles: string[] = [];
|
|
143
|
+
const entries: Entry[] = [];
|
|
144
|
+
const orphans: Array<{ i: number; id: string | null }> = [];
|
|
145
|
+
const late: Array<{ i: number; id: string | null }> = [];
|
|
146
|
+
const byAssistant = new Map<number, number[]>();
|
|
147
|
+
const everDeclared = new Set<string>();
|
|
148
|
+
let lastAssistant = -1;
|
|
149
|
+
let lastIds = new Set<string>();
|
|
150
|
+
let calls = 0;
|
|
151
|
+
let results = 0;
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < messages.length; i++) {
|
|
154
|
+
const m = (messages[i] ?? {}) as WireMessage;
|
|
155
|
+
const role = typeof m.role === "string" ? m.role : "?";
|
|
156
|
+
roles.push(role);
|
|
157
|
+
|
|
158
|
+
if (role === "assistant") {
|
|
159
|
+
const ids = declaredIds(m);
|
|
160
|
+
lastAssistant = i;
|
|
161
|
+
lastIds = new Set(ids);
|
|
162
|
+
calls += ids.length;
|
|
163
|
+
for (const id of ids) everDeclared.add(id);
|
|
164
|
+
if (ids.length > 0) {
|
|
165
|
+
const paired: number[] = [];
|
|
166
|
+
entries.push({ i, r: role, tc: ids.length, ids, paired });
|
|
167
|
+
byAssistant.set(i, paired);
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (role === "tool") {
|
|
173
|
+
results += 1;
|
|
174
|
+
const id = answeredId(m);
|
|
175
|
+
const declared = id !== null && everDeclared.has(id);
|
|
176
|
+
const ok = declared && lastIds.has(id);
|
|
177
|
+
entries.push({ i, r: role, id, after: lastAssistant, ok, prev: declared });
|
|
178
|
+
if (!declared) orphans.push({ i, id });
|
|
179
|
+
else if (!ok) late.push({ i, id });
|
|
180
|
+
if (ok) byAssistant.get(lastAssistant)?.push(i);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { roles, entries, orphans, late, calls, results };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export default function (pi: ExtensionAPI): void {
|
|
188
|
+
// Child sessions only: the async runner marks its hosted session with
|
|
189
|
+
// PI_SUBAGENT_CHILD, the pi-subagent wrapper exports PI_SUBAGENT.
|
|
190
|
+
if (process.env.PI_SUBAGENT_CHILD !== "1" && process.env.PI_SUBAGENT !== "1") return;
|
|
191
|
+
|
|
192
|
+
const PATH = logPath();
|
|
193
|
+
const ROTATED = `${PATH}.1`;
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
mkdirSync(dirname(PATH), { recursive: true });
|
|
197
|
+
} catch {
|
|
198
|
+
/* best effort: the append below is guarded regardless */
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let req = 0;
|
|
202
|
+
let run = 0;
|
|
203
|
+
let bytes = 0;
|
|
204
|
+
let rotating = false;
|
|
205
|
+
try {
|
|
206
|
+
bytes = statSync(PATH).size;
|
|
207
|
+
} catch {
|
|
208
|
+
bytes = 0;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Run-start identity, as recorded by cache-prefix-log: agent_start opens a run,
|
|
212
|
+
// and before_agent_start fires only for a run started by the prompt path.
|
|
213
|
+
let promptHookFired = false;
|
|
214
|
+
let origin: "prompt" | "injected" | null = null;
|
|
215
|
+
pi.on("before_agent_start", () => {
|
|
216
|
+
promptHookFired = true;
|
|
217
|
+
});
|
|
218
|
+
pi.on("agent_start", () => {
|
|
219
|
+
run += 1;
|
|
220
|
+
origin = promptHookFired ? "prompt" : "injected";
|
|
221
|
+
promptHookFired = false;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
function schedule(line: string): void {
|
|
225
|
+
const len = Buffer.byteLength(line);
|
|
226
|
+
// Hard cap: at or over CAP the file is rotated once, and rows are dropped
|
|
227
|
+
// until the rename lands, so PATH cannot exceed CAP_BYTES under a burst.
|
|
228
|
+
if (bytes + len > CAP_BYTES) {
|
|
229
|
+
if (!rotating) {
|
|
230
|
+
rotating = true;
|
|
231
|
+
bytes = CAP_BYTES;
|
|
232
|
+
rename(PATH, ROTATED, () => {
|
|
233
|
+
rotating = false;
|
|
234
|
+
bytes = 0;
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
bytes += len;
|
|
240
|
+
appendFile(PATH, line, () => {
|
|
241
|
+
/* best effort: a recorder fault must never surface */
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
246
|
+
try {
|
|
247
|
+
const payload = (event as { payload?: Record<string, unknown> })?.payload;
|
|
248
|
+
const messages = Array.isArray(payload?.messages) ? (payload.messages as unknown[]) : null;
|
|
249
|
+
if (!messages) return undefined;
|
|
250
|
+
|
|
251
|
+
const shape = scanShape(messages);
|
|
252
|
+
let sess = "";
|
|
253
|
+
try {
|
|
254
|
+
sess = String(ctx?.sessionManager?.getSessionId?.() ?? "").slice(0, 8);
|
|
255
|
+
} catch {
|
|
256
|
+
sess = "";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const row: Record<string, unknown> = {
|
|
260
|
+
ts: new Date().toISOString(),
|
|
261
|
+
pid: process.pid,
|
|
262
|
+
sess,
|
|
263
|
+
run,
|
|
264
|
+
req: ++req,
|
|
265
|
+
origin: origin ?? (promptHookFired ? "prompt" : "injected"),
|
|
266
|
+
model: typeof payload?.model === "string" ? payload.model : null,
|
|
267
|
+
n: messages.length,
|
|
268
|
+
calls: shape.calls,
|
|
269
|
+
results: shape.results,
|
|
270
|
+
orphans: shape.orphans,
|
|
271
|
+
late: shape.late,
|
|
272
|
+
roles: shape.roles,
|
|
273
|
+
msgs: shape.entries,
|
|
274
|
+
};
|
|
275
|
+
schedule(`${JSON.stringify(row)}\n`);
|
|
276
|
+
} catch {
|
|
277
|
+
/* a recorder fault must never be the reason a request fails */
|
|
278
|
+
}
|
|
279
|
+
return undefined;
|
|
280
|
+
});
|
|
281
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tinoy/pi-child-request-dump",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Structure-only JSONL record of each outbound provider request in a child session.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/tinoy1336/pi-extensions.git",
|
|
9
|
+
"directory": "packages/child-request-dump"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/tinoy1336/pi-extensions/tree/main/packages/child-request-dump#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/tinoy1336/pi-extensions/issues"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "child-request-dump.ts",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"pi-package"
|
|
19
|
+
],
|
|
20
|
+
"files": [
|
|
21
|
+
"child-request-dump.ts",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"pi": {
|
|
26
|
+
"extensions": [
|
|
27
|
+
"./child-request-dump.ts"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
35
|
+
}
|
|
36
|
+
}
|