@saccolabs/pi-claude-cli 0.4.3 → 0.4.4
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/package.json +1 -1
- package/src/event-bridge.ts +122 -96
- package/src/types.ts +4 -0
package/package.json
CHANGED
package/src/event-bridge.ts
CHANGED
|
@@ -28,6 +28,8 @@ interface TrackedToolBlock {
|
|
|
28
28
|
type: "tool_use";
|
|
29
29
|
index: number;
|
|
30
30
|
cycle: number;
|
|
31
|
+
/** Position in output.content, or -1 when not materialized. */
|
|
32
|
+
contentIndex: number;
|
|
31
33
|
id: string;
|
|
32
34
|
name: string; // Already mapped to pi name
|
|
33
35
|
claudeName: string; // Original Claude name for arg translation
|
|
@@ -152,20 +154,18 @@ export function createEventBridge(
|
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
/**
|
|
155
|
-
* Append a complete text block through proper pi stream events.
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
* into both with the same idx), so the marker must occupy a slot in both;
|
|
159
|
-
* cycle -1 / index -1 can never match a real SSE event.
|
|
157
|
+
* Append a complete text block through proper pi stream events. Tracked
|
|
158
|
+
* blocks carry their own contentIndex, so appended blocks (markers, the
|
|
159
|
+
* final-answer safety net) need no relationship to any SSE index.
|
|
160
160
|
*/
|
|
161
161
|
function appendTextBlock(text: string): void {
|
|
162
162
|
if (!started) {
|
|
163
163
|
stream.push({ type: "start", partial: output });
|
|
164
164
|
started = true;
|
|
165
165
|
}
|
|
166
|
-
|
|
166
|
+
const contentIndex = output.content.length;
|
|
167
|
+
blocks.push({ type: "text", text, index: -1, cycle: -1, contentIndex });
|
|
167
168
|
output.content.push({ type: "text" as const, text: "" });
|
|
168
|
-
const contentIndex = output.content.length - 1;
|
|
169
169
|
stream.push({ type: "text_start", contentIndex, partial: output });
|
|
170
170
|
(output.content[contentIndex] as TextContent).text = text;
|
|
171
171
|
stream.push({
|
|
@@ -238,34 +238,32 @@ export function createEventBridge(
|
|
|
238
238
|
text: "",
|
|
239
239
|
index: event.index ?? 0,
|
|
240
240
|
cycle,
|
|
241
|
+
contentIndex: output.content.length,
|
|
241
242
|
};
|
|
242
243
|
blocks.push(block);
|
|
243
244
|
output.content.push({ type: "text" as const, text: "" });
|
|
244
245
|
|
|
245
246
|
stream.push({
|
|
246
247
|
type: "text_start",
|
|
247
|
-
contentIndex:
|
|
248
|
+
contentIndex: block.contentIndex,
|
|
248
249
|
partial: output,
|
|
249
250
|
});
|
|
250
251
|
} else if (blockType === "thinking") {
|
|
252
|
+
// Deliberately NOT materialized yet. Several Claude models (verified:
|
|
253
|
+
// fable-5, opus-5, sonnet-5 at effort medium; haiku-4-5 is the
|
|
254
|
+
// exception) stream ENCRYPTED thinking: a multi-kilobyte
|
|
255
|
+
// signature_delta with no thinking_delta at all. Materializing on
|
|
256
|
+
// start produced a thinking block with no text, which front-ends
|
|
257
|
+
// faithfully rendered as an empty "thought". The block is created on
|
|
258
|
+
// the first plaintext delta and dropped at block_stop if none arrives.
|
|
251
259
|
const block: TrackedContentBlock = {
|
|
252
260
|
type: "thinking",
|
|
253
261
|
text: "",
|
|
254
262
|
index: event.index ?? 0,
|
|
255
263
|
cycle,
|
|
264
|
+
contentIndex: -1,
|
|
256
265
|
};
|
|
257
266
|
blocks.push(block);
|
|
258
|
-
output.content.push({
|
|
259
|
-
type: "thinking" as const,
|
|
260
|
-
thinking: "",
|
|
261
|
-
thinkingSignature: "",
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
stream.push({
|
|
265
|
-
type: "thinking_start",
|
|
266
|
-
contentIndex: output.content.length - 1,
|
|
267
|
-
partial: output,
|
|
268
|
-
});
|
|
269
267
|
} else if (blockType === "tool_use") {
|
|
270
268
|
const claudeName = event.content_block!.name!;
|
|
271
269
|
|
|
@@ -282,6 +280,7 @@ export function createEventBridge(
|
|
|
282
280
|
type: "tool_use",
|
|
283
281
|
index: event.index ?? 0,
|
|
284
282
|
cycle,
|
|
283
|
+
contentIndex: output.content.length,
|
|
285
284
|
id,
|
|
286
285
|
name: piName,
|
|
287
286
|
claudeName,
|
|
@@ -298,101 +297,112 @@ export function createEventBridge(
|
|
|
298
297
|
|
|
299
298
|
stream.push({
|
|
300
299
|
type: "toolcall_start",
|
|
301
|
-
contentIndex:
|
|
300
|
+
contentIndex: block.contentIndex,
|
|
302
301
|
partial: output,
|
|
303
302
|
});
|
|
304
303
|
}
|
|
305
304
|
// Unknown block types silently ignored
|
|
306
305
|
}
|
|
307
306
|
|
|
307
|
+
/** Locate the tracked block for this cycle's SSE index. */
|
|
308
|
+
function trackedFor(event: ClaudeApiEvent): TrackedBlock | undefined {
|
|
309
|
+
const idx = blocks.findIndex(
|
|
310
|
+
(b) => b.cycle === cycle && b.index === event.index,
|
|
311
|
+
);
|
|
312
|
+
return idx === -1 ? undefined : blocks[idx];
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Create the pi content block for a thinking block that has now proven it
|
|
317
|
+
* carries plaintext. Encrypted thinking never reaches this path, so it
|
|
318
|
+
* never becomes an empty "thought" downstream.
|
|
319
|
+
*/
|
|
320
|
+
function materializeThinking(block: TrackedContentBlock): void {
|
|
321
|
+
block.contentIndex = output.content.length;
|
|
322
|
+
output.content.push({
|
|
323
|
+
type: "thinking" as const,
|
|
324
|
+
thinking: "",
|
|
325
|
+
thinkingSignature: block.pendingSignature ?? "",
|
|
326
|
+
});
|
|
327
|
+
block.pendingSignature = undefined;
|
|
328
|
+
stream.push({
|
|
329
|
+
type: "thinking_start",
|
|
330
|
+
contentIndex: block.contentIndex,
|
|
331
|
+
partial: output,
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
308
335
|
function handleContentBlockDelta(event: ClaudeApiEvent): void {
|
|
309
336
|
const deltaType = event.delta?.type;
|
|
337
|
+
const block = trackedFor(event);
|
|
338
|
+
if (!block) return;
|
|
310
339
|
|
|
311
340
|
if (deltaType === "text_delta" && event.delta!.text != null) {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
);
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
contentBlock.text = block.text;
|
|
322
|
-
|
|
323
|
-
stream.push({
|
|
324
|
-
type: "text_delta",
|
|
325
|
-
contentIndex: idx,
|
|
326
|
-
delta: event.delta!.text,
|
|
327
|
-
partial: output,
|
|
328
|
-
});
|
|
329
|
-
}
|
|
341
|
+
if (block.type !== "text") return;
|
|
342
|
+
block.text += event.delta!.text;
|
|
343
|
+
(output.content[block.contentIndex] as TextContent).text = block.text;
|
|
344
|
+
stream.push({
|
|
345
|
+
type: "text_delta",
|
|
346
|
+
contentIndex: block.contentIndex,
|
|
347
|
+
delta: event.delta!.text,
|
|
348
|
+
partial: output,
|
|
349
|
+
});
|
|
330
350
|
} else if (
|
|
331
351
|
deltaType === "thinking_delta" &&
|
|
332
352
|
event.delta!.thinking != null
|
|
333
353
|
) {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
)
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
block.text += event.delta!.thinking;
|
|
342
|
-
const contentBlock = output.content[idx] as ThinkingContent;
|
|
343
|
-
contentBlock.thinking = block.text;
|
|
344
|
-
|
|
345
|
-
stream.push({
|
|
346
|
-
type: "thinking_delta",
|
|
347
|
-
contentIndex: idx,
|
|
348
|
-
delta: event.delta!.thinking,
|
|
349
|
-
partial: output,
|
|
350
|
-
});
|
|
354
|
+
if (block.type !== "thinking") return;
|
|
355
|
+
// Empty thinking_delta events accompany encrypted thinking (verified
|
|
356
|
+
// on sonnet-5): they carry no plaintext, so they must not bring a
|
|
357
|
+
// thinking block into existence.
|
|
358
|
+
if (block.contentIndex === -1) {
|
|
359
|
+
if (event.delta!.thinking.length === 0) return;
|
|
360
|
+
materializeThinking(block);
|
|
351
361
|
}
|
|
362
|
+
block.text += event.delta!.thinking;
|
|
363
|
+
(output.content[block.contentIndex] as ThinkingContent).thinking =
|
|
364
|
+
block.text;
|
|
365
|
+
stream.push({
|
|
366
|
+
type: "thinking_delta",
|
|
367
|
+
contentIndex: block.contentIndex,
|
|
368
|
+
delta: event.delta!.thinking,
|
|
369
|
+
partial: output,
|
|
370
|
+
});
|
|
352
371
|
} else if (
|
|
353
372
|
deltaType === "input_json_delta" &&
|
|
354
373
|
event.delta!.partial_json != null
|
|
355
374
|
) {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
block.partialJson += event.delta!.partial_json;
|
|
364
|
-
|
|
365
|
-
// Try to parse accumulated JSON -- on success update args, on failure keep previous
|
|
366
|
-
try {
|
|
367
|
-
block.arguments = JSON.parse(block.partialJson);
|
|
368
|
-
(output.content[idx] as any).arguments = block.arguments;
|
|
369
|
-
} catch {
|
|
370
|
-
// Partial JSON not yet parseable -- keep previous arguments
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
stream.push({
|
|
374
|
-
type: "toolcall_delta",
|
|
375
|
-
contentIndex: idx,
|
|
376
|
-
delta: event.delta!.partial_json,
|
|
377
|
-
partial: output,
|
|
378
|
-
});
|
|
375
|
+
if (block.type !== "tool_use") return;
|
|
376
|
+
block.partialJson += event.delta!.partial_json;
|
|
377
|
+
try {
|
|
378
|
+
block.arguments = JSON.parse(block.partialJson);
|
|
379
|
+
(output.content[block.contentIndex] as any).arguments = block.arguments;
|
|
380
|
+
} catch {
|
|
381
|
+
// Partial JSON not yet parseable -- keep previous arguments
|
|
379
382
|
}
|
|
383
|
+
stream.push({
|
|
384
|
+
type: "toolcall_delta",
|
|
385
|
+
contentIndex: block.contentIndex,
|
|
386
|
+
delta: event.delta!.partial_json,
|
|
387
|
+
partial: output,
|
|
388
|
+
});
|
|
380
389
|
} else if (
|
|
381
390
|
deltaType === "signature_delta" &&
|
|
382
391
|
event.delta!.signature != null
|
|
383
392
|
) {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
(
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
if (block.type === "thinking") {
|
|
392
|
-
const contentBlock = output.content[idx] as ThinkingContent;
|
|
393
|
-
contentBlock.thinkingSignature =
|
|
394
|
-
(contentBlock.thinkingSignature || "") + event.delta!.signature;
|
|
393
|
+
if (block.type !== "thinking") return;
|
|
394
|
+
if (block.contentIndex === -1) {
|
|
395
|
+
// Signature before (or without) any plaintext: hold it in case
|
|
396
|
+
// plaintext follows. If it never does, the block is dropped.
|
|
397
|
+
block.pendingSignature =
|
|
398
|
+
(block.pendingSignature ?? "") + event.delta!.signature;
|
|
399
|
+
return;
|
|
395
400
|
}
|
|
401
|
+
const contentBlock = output.content[
|
|
402
|
+
block.contentIndex
|
|
403
|
+
] as ThinkingContent;
|
|
404
|
+
contentBlock.thinkingSignature =
|
|
405
|
+
(contentBlock.thinkingSignature || "") + event.delta!.signature;
|
|
396
406
|
}
|
|
397
407
|
}
|
|
398
408
|
|
|
@@ -401,22 +411,29 @@ export function createEventBridge(
|
|
|
401
411
|
(b) => b.cycle === cycle && b.index === event.index,
|
|
402
412
|
);
|
|
403
413
|
if (idx === -1) return;
|
|
404
|
-
|
|
405
414
|
const block = blocks[idx];
|
|
415
|
+
|
|
416
|
+
// Encrypted thinking: signature only, no plaintext. Drop it rather than
|
|
417
|
+
// emit a content block with nothing to show.
|
|
418
|
+
if (block.type === "thinking" && block.contentIndex === -1) {
|
|
419
|
+
blocks.splice(idx, 1);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
406
423
|
// Clean up the tracking index from the block (no longer needed)
|
|
407
424
|
delete (block as any).index;
|
|
408
425
|
|
|
409
426
|
if (block.type === "text") {
|
|
410
427
|
stream.push({
|
|
411
428
|
type: "text_end",
|
|
412
|
-
contentIndex:
|
|
429
|
+
contentIndex: block.contentIndex,
|
|
413
430
|
content: block.text,
|
|
414
431
|
partial: output,
|
|
415
432
|
});
|
|
416
433
|
} else if (block.type === "thinking") {
|
|
417
434
|
stream.push({
|
|
418
435
|
type: "thinking_end",
|
|
419
|
-
contentIndex:
|
|
436
|
+
contentIndex: block.contentIndex,
|
|
420
437
|
content: block.text,
|
|
421
438
|
partial: output,
|
|
422
439
|
});
|
|
@@ -430,9 +447,7 @@ export function createEventBridge(
|
|
|
430
447
|
finalArgs = block.partialJson;
|
|
431
448
|
}
|
|
432
449
|
|
|
433
|
-
|
|
434
|
-
const contentBlock = output.content[idx] as ToolCall;
|
|
435
|
-
(contentBlock as any).arguments = finalArgs;
|
|
450
|
+
(output.content[block.contentIndex] as any).arguments = finalArgs;
|
|
436
451
|
|
|
437
452
|
// ToolCall.arguments is typed as Record<string, any> in pi-ai, but we
|
|
438
453
|
// intentionally emit a raw string when JSON parse fails completely.
|
|
@@ -446,7 +461,7 @@ export function createEventBridge(
|
|
|
446
461
|
|
|
447
462
|
stream.push({
|
|
448
463
|
type: "toolcall_end",
|
|
449
|
-
contentIndex:
|
|
464
|
+
contentIndex: block.contentIndex,
|
|
450
465
|
toolCall,
|
|
451
466
|
partial: output,
|
|
452
467
|
});
|
|
@@ -502,6 +517,17 @@ export function createEventBridge(
|
|
|
502
517
|
} catch {
|
|
503
518
|
/* unserializable input — marker still names the tool */
|
|
504
519
|
}
|
|
520
|
+
// WIRE CONTRACT — front-ends parse this string.
|
|
521
|
+
//
|
|
522
|
+
// [Claude Code · <ToolName>] (no arguments)
|
|
523
|
+
// [Claude Code · <ToolName> <argsJson>] (preview, may be truncated)
|
|
524
|
+
//
|
|
525
|
+
// pidex matches /^\[Claude Code · ([^\s\]]+)(?:\s+([\s\S]*))?\]$/ to
|
|
526
|
+
// render these as activity rows instead of prose; anything it cannot
|
|
527
|
+
// match falls back to being shown as raw markdown, which is what this
|
|
528
|
+
// marker existed to avoid. Change the shape only together with the
|
|
529
|
+
// consumers, and keep the argument preview opaque — it is truncated
|
|
530
|
+
// here, so it is frequently invalid JSON and must never be parsed.
|
|
505
531
|
appendTextBlock(`[Claude Code · ${block.name}${argsPreview}]`);
|
|
506
532
|
}
|
|
507
533
|
}
|
package/src/types.ts
CHANGED
|
@@ -128,4 +128,8 @@ export interface TrackedContentBlock {
|
|
|
128
128
|
text: string;
|
|
129
129
|
index: number; // Claude's content_block index (resets each cycle)
|
|
130
130
|
cycle: number; // Which API call of the episode this block belongs to
|
|
131
|
+
/** Position in output.content, or -1 while not materialized. */
|
|
132
|
+
contentIndex: number;
|
|
133
|
+
/** signature_delta received before any plaintext (encrypted thinking). */
|
|
134
|
+
pendingSignature?: string;
|
|
131
135
|
}
|