hob-bot 1.0.3 → 1.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.
Files changed (86) hide show
  1. package/README.md +75 -5
  2. package/README.zh-CN.md +75 -5
  3. package/dist/web/assets/DesktopView-rVxacveF.js +5 -0
  4. package/dist/web/assets/index-B-8ISOr1.js +267 -0
  5. package/dist/web/assets/index-Cu1Pf8yX.css +1 -0
  6. package/dist/web/assets/{math-zZ4JA8eM.js → math-weKGDUmL.js} +2 -2
  7. package/dist/web/index.html +2 -2
  8. package/dist/web/mock/desktop.jpg +0 -0
  9. package/dist/web/mock/photo.jpg +0 -0
  10. package/dist/web/mock/photo2.jpg +0 -0
  11. package/dist/web/mock/sunset.html +90 -0
  12. package/dist/web/mock//347/201/253/346/230/237/346/227/245/350/220/275/347/244/272/346/204/217/345/233/276.md +34 -0
  13. package/package.json +10 -2
  14. package/server/src/actions.ts +24 -57
  15. package/server/src/assistant-skills.ts +201 -0
  16. package/server/src/boot.ts +1 -0
  17. package/server/src/claude-status.ts +47 -0
  18. package/server/src/config.ts +3 -1
  19. package/server/src/delegations.ts +40 -12
  20. package/server/src/desktop-control.ts +152 -0
  21. package/server/src/desktop-events.ts +63 -0
  22. package/server/src/desktop-public.ts +43 -0
  23. package/server/src/desktop-tools.ts +161 -0
  24. package/server/src/desktop.ts +76 -0
  25. package/server/src/embedding.ts +52 -0
  26. package/server/src/files.ts +63 -43
  27. package/server/src/http.ts +334 -15
  28. package/server/src/jobs.ts +330 -59
  29. package/server/src/knowledge-index.ts +123 -0
  30. package/server/src/knowledge-tools.ts +94 -0
  31. package/server/src/knowledge.ts +451 -0
  32. package/server/src/lore-entry.ts +10 -0
  33. package/server/src/main.ts +6 -0
  34. package/server/src/mcp.ts +745 -0
  35. package/server/src/memory.ts +11 -4
  36. package/server/src/now.ts +33 -0
  37. package/server/src/overview.ts +1 -1
  38. package/server/src/pages.ts +1 -1
  39. package/server/src/pi.ts +92 -16
  40. package/server/src/prompt.ts +46 -154
  41. package/server/src/providers.ts +3 -2
  42. package/server/src/push.ts +42 -7
  43. package/server/src/router-activity.ts +4 -0
  44. package/server/src/router-tools.ts +132 -38
  45. package/server/src/router.ts +159 -19
  46. package/server/src/routines.ts +14 -4
  47. package/server/src/runtime.ts +15 -0
  48. package/server/src/settings.ts +43 -1
  49. package/server/src/store.ts +4 -3
  50. package/server/src/thumbs.ts +49 -0
  51. package/server/src/transcript.ts +57 -4
  52. package/server/src/worker-tools.ts +14 -12
  53. package/shared/knowledge.ts +92 -0
  54. package/shared/mcp.ts +91 -0
  55. package/shared/media.ts +6 -0
  56. package/shared/plain-text.ts +31 -0
  57. package/shared/skills.ts +18 -0
  58. package/shared/sprites.ts +198 -0
  59. package/shared/types.ts +105 -20
  60. package/vendor/claude-bridge/HOB-PATCH.md +10 -0
  61. package/vendor/claude-bridge/LICENSE +21 -0
  62. package/vendor/claude-bridge/package.json +62 -0
  63. package/vendor/claude-bridge/src/agents-md.ts +14 -0
  64. package/vendor/claude-bridge/src/askclaude-schema.ts +92 -0
  65. package/vendor/claude-bridge/src/askclaude-ui.ts +90 -0
  66. package/vendor/claude-bridge/src/attachments.ts +135 -0
  67. package/vendor/claude-bridge/src/config.ts +105 -0
  68. package/vendor/claude-bridge/src/convert.ts +222 -0
  69. package/vendor/claude-bridge/src/extract-tool-results.ts +47 -0
  70. package/vendor/claude-bridge/src/index.ts +2503 -0
  71. package/vendor/claude-bridge/src/log-paths.ts +19 -0
  72. package/vendor/claude-bridge/src/mcp-server.ts +169 -0
  73. package/vendor/claude-bridge/src/models.ts +164 -0
  74. package/vendor/claude-bridge/src/prompt-capture.ts +420 -0
  75. package/vendor/claude-bridge/src/prompt-stream.ts +102 -0
  76. package/vendor/claude-bridge/src/query-state.ts +118 -0
  77. package/vendor/claude-bridge/src/rate-limit.ts +48 -0
  78. package/vendor/claude-bridge/src/session-cwd.ts +38 -0
  79. package/vendor/claude-bridge/src/session-verify.ts +38 -0
  80. package/vendor/claude-bridge/src/skills.ts +20 -0
  81. package/vendor/claude-bridge/src/transcript.ts +86 -0
  82. package/vendor/claude-bridge/src/usage.ts +53 -0
  83. package/vendor/lore-pi-extension/package.json +1 -1
  84. package/vendor/lore-pi-extension/tools.ts +8 -5
  85. package/dist/web/assets/index-C72gAwgN.css +0 -1
  86. package/dist/web/assets/index-Dyw5G8Pg.js +0 -230
@@ -0,0 +1,135 @@
1
+ // Carrying Claude Code's own attachments across a session rebuild.
2
+ //
3
+ // CC expands an `@file` mention itself — pi passes `@` through untouched — and
4
+ // writes the expansion as a `type: "attachment"` record in its session file. pi
5
+ // never sees it, so rebuilding a session from pi's history drops the file while
6
+ // keeping the prompt text that referred to it: the model silently loses
7
+ // something it was reasoning about, with nothing logged.
8
+ //
9
+ // Extracted from index.ts so tests can import it without activating the extension.
10
+
11
+ import type { JsonlRecord, ImportAttachment } from "cc-session-io";
12
+ import { messageContentToText } from "./convert.js";
13
+
14
+ // Only `@file` expansions are carried. They are the one thing pi genuinely never
15
+ // sees, so a rebuild is the only chance to keep them.
16
+ //
17
+ // `edited_text_file` is deliberately excluded even though it also carries file
18
+ // content. CC writes one after editing a file, and the edit itself is already in
19
+ // pi's history as a tool call and its result, so the attachment duplicates context
20
+ // the rebuild reproduces anyway. It also usually hangs off a *tool result* record
21
+ // rather than a prompt, which has no position in the ordinal scheme below — on
22
+ // real sessions that left 81 of them unresolvable (see
23
+ // diag/attachment-coverage.mjs). Half-carrying a kind is worse than not claiming
24
+ // it: the ones that slipped through would be an arbitrary subset.
25
+ //
26
+ // Everything else CC rewrites every turn (`skill_listing`, `task_reminder`,
27
+ // `agent_listing_delta`, `mcp_instructions_delta`, …) and loses nothing.
28
+ const CONTENT_BEARING = new Set(["file"]);
29
+
30
+ export type CarriedAttachment = {
31
+ attachment: { type: string; [key: string]: unknown };
32
+ /** Position of the parent among the session's text-bearing user records. */
33
+ userOrdinal: number;
34
+ /** That record's text, to verify the ordinal still points at the same turn. */
35
+ parentText: string;
36
+ };
37
+
38
+ type Rec = Record<string, unknown>;
39
+
40
+ /** A user record holding a prompt, as opposed to one holding tool results. */
41
+ function userPromptText(record: Rec): string | undefined {
42
+ if (record.type !== "user") return undefined;
43
+ const content = (record.message as Rec | undefined)?.content;
44
+ if (Array.isArray(content) && content.some((b) => (b as Rec)?.type === "tool_result")) return undefined;
45
+ const text = messageContentToText(content as never);
46
+ return text ? text : undefined;
47
+ }
48
+
49
+ /**
50
+ * Content-bearing attachments in a session, each tagged with where its parent
51
+ * sits among the text-bearing user records.
52
+ *
53
+ * The ordinal is the mapping key rather than the record index: a rebuild does not
54
+ * reproduce the old record list one-for-one — `importMessages` splits a message
55
+ * carrying tool results into two records, and CC appends records of its own — but
56
+ * the sequence of user prompts is the same conversation either way.
57
+ *
58
+ * Attachments also chain to one another, so an ordinal is resolved transitively up
59
+ * the parent links until it reaches a prompt. Most real attachments are
60
+ * `edited_text_file` records CC writes after editing a file, which have nothing to
61
+ * do with at-mentions; only their position in the conversation matters here.
62
+ */
63
+ export function collectCarriedAttachments(records: readonly JsonlRecord[]): CarriedAttachment[] {
64
+ const ordinalOf = new Map<string, number>();
65
+ const textOf = new Map<string, string>();
66
+ let ordinal = 0;
67
+ const carried: CarriedAttachment[] = [];
68
+
69
+ for (const raw of records) {
70
+ const record = raw as Rec;
71
+ const prompt = userPromptText(record);
72
+ if (prompt !== undefined) {
73
+ ordinalOf.set(record.uuid as string, ordinal++);
74
+ textOf.set(record.uuid as string, prompt);
75
+ continue;
76
+ }
77
+ if (record.type !== "attachment") continue;
78
+ const parent = record.parentUuid as string | null;
79
+ // Attachments chain to each other — a run of them hangs off one prompt, and
80
+ // 63 of 179 in real sessions parent to another attachment rather than to a
81
+ // message. Inherit the ordinal so the whole run keys to the prompt that
82
+ // caused it. Recorded for every attachment, not just the ones carried, since
83
+ // a content-bearing one can chain off a `skill_listing` we ignore.
84
+ if (parent === null || !ordinalOf.has(parent)) continue;
85
+ const inherited = ordinalOf.get(parent)!;
86
+ ordinalOf.set(record.uuid as string, inherited);
87
+ textOf.set(record.uuid as string, textOf.get(parent)!);
88
+
89
+ const attachment = record.attachment as { type: string; [key: string]: unknown } | undefined;
90
+ if (!attachment || !CONTENT_BEARING.has(attachment.type)) continue;
91
+ carried.push({ attachment, userOrdinal: inherited, parentText: textOf.get(parent)! });
92
+ }
93
+ return carried;
94
+ }
95
+
96
+ /**
97
+ * Resolve each carried attachment to a position in the array about to be
98
+ * imported — the messages *after* conversion and repair, since that is the index
99
+ * space `importMessages` reads. Repair is idempotent, so an already-repaired array
100
+ * passes through its second run unchanged and the indices stay valid.
101
+ *
102
+ * Deliberately conservative: attaching a file to the wrong turn tells the model it
103
+ * saw something at a point it did not, which is worse than the loss this exists to
104
+ * prevent. So the ordinal has to land on a prompt whose text still matches; any
105
+ * disagreement is reported and dropped rather than approximated.
106
+ */
107
+ export function placeCarriedAttachments(
108
+ carried: readonly CarriedAttachment[],
109
+ messages: readonly { role: string; content: unknown }[],
110
+ ): { attachments: ImportAttachment[]; skipped: string[] } {
111
+ const prompts: { index: number; text: string }[] = [];
112
+ messages.forEach((msg, index) => {
113
+ if (msg.role !== "user") return;
114
+ if (Array.isArray(msg.content) && msg.content.some((b) => (b as Rec)?.type === "tool_result")) return;
115
+ const text = messageContentToText(msg.content as never);
116
+ if (text) prompts.push({ index, text });
117
+ });
118
+
119
+ const attachments: ImportAttachment[] = [];
120
+ const skipped: string[] = [];
121
+ for (const item of carried) {
122
+ const name = String(item.attachment.filename ?? item.attachment.type);
123
+ const candidate = prompts[item.userOrdinal];
124
+ if (!candidate) {
125
+ skipped.push(`${name}: prompt #${item.userOrdinal} is no longer in history`);
126
+ continue;
127
+ }
128
+ if (candidate.text !== item.parentText) {
129
+ skipped.push(`${name}: prompt #${item.userOrdinal} changed`);
130
+ continue;
131
+ }
132
+ attachments.push({ afterIndex: candidate.index, attachment: item.attachment });
133
+ }
134
+ return { attachments, skipped };
135
+ }
@@ -0,0 +1,105 @@
1
+ // Hob loads only the bridge configuration written in its own engine directory.
2
+ // Missing or unparseable files are reported and treated as an empty object.
3
+
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
6
+ import { dirname, join } from "path";
7
+
8
+ export interface Config {
9
+ /** Date (YYYY-MM-DD) the one-time startup notice was shown. Written by the extension, not the user. */
10
+ startupNoticeShown?: string;
11
+ /** Date (YYYY-MM-DD) the AskClaude deprecation notice was shown. Written by the extension, not the user. */
12
+ askClaudeDeprecationShown?: string;
13
+ askClaude?: {
14
+ enabled?: boolean;
15
+ name?: string;
16
+ label?: string;
17
+ description?: string;
18
+ defaultMode?: "full" | "read" | "none";
19
+ defaultIsolated?: boolean;
20
+ allowFullMode?: boolean;
21
+ appendSkills?: boolean;
22
+ };
23
+ /** Low-level Claude Agent SDK plumbing. Most users won't need these. */
24
+ provider?: {
25
+ strictMcpConfig?: boolean;
26
+ autoMemoryEnabled?: boolean;
27
+ pathToClaudeCodeExecutable?: string;
28
+ // Subscription plan tier. Setting to "max" enables Opus 4.6 at 1M context
29
+ plan?: "pro" | "max";
30
+ // Set to true to opt into metered 1M context usage ("extra usage" in
31
+ // Anthropic billing). Enables Sonnet 4.6 [1m] on every plan and Opus 4.6
32
+ // [1m] on Pro.
33
+ longContextExtraUsage?: boolean;
34
+ // Model ids (e.g. "claude-future-9") whose declared 1M context Claude Code
35
+ // does not actually serve; pins them to the bare id at 200K.
36
+ forceTwoHundredK?: string[];
37
+ };
38
+ }
39
+
40
+ export function tryParseJson(path: string): Partial<Config> {
41
+ if (!existsSync(path)) return {};
42
+ try {
43
+ return JSON.parse(readFileSync(path, "utf-8"));
44
+ } catch (e) {
45
+ console.error(`claude-bridge: failed to parse ${path}: ${e}`);
46
+ return {};
47
+ }
48
+ }
49
+
50
+ export function claudeCodeSettings(provider: Config["provider"] = {}): { autoMemoryEnabled: boolean } {
51
+ return { autoMemoryEnabled: provider.autoMemoryEnabled ?? false };
52
+ }
53
+
54
+ export function globalConfigPath(): string {
55
+ return join(getAgentDir(), "claude-bridge.json");
56
+ }
57
+
58
+ /** Record today's date in the global config so the startup notice shows once, preserving every
59
+ * other field. Returns the config path for display either way.
60
+ *
61
+ * Parses directly rather than through tryParseJson, which reports an unparseable file as `{}`:
62
+ * spreading that would replace a user's whole config with just this marker the first time they
63
+ * leave a trailing comma in it. Losing the notice is the cheaper failure, so the write is
64
+ * skipped and the notice simply shows again next session. */
65
+ export function markStartupNoticeShown(): string {
66
+ const path = globalConfigPath();
67
+ let existing: Partial<Config> = {};
68
+ if (existsSync(path)) {
69
+ try {
70
+ existing = JSON.parse(readFileSync(path, "utf-8"));
71
+ } catch (e) {
72
+ console.error(`claude-bridge: leaving ${path} alone, it does not parse: ${e}`);
73
+ return path;
74
+ }
75
+ }
76
+ // en-CA renders YYYY-MM-DD in local time; toISOString() would report UTC.
77
+ const next = { ...existing, startupNoticeShown: new Date().toLocaleDateString("en-CA") };
78
+ mkdirSync(dirname(path), { recursive: true });
79
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
80
+ return path;
81
+ }
82
+
83
+ /** Record when the AskClaude deprecation notice was shown in the global config, preserving every
84
+ * other field. Returns the config path either way. */
85
+ export function markAskClaudeDeprecationNoticeShown(): string {
86
+ const path = globalConfigPath();
87
+ let existing: Partial<Config> = {};
88
+ if (existsSync(path)) {
89
+ try {
90
+ existing = JSON.parse(readFileSync(path, "utf-8"));
91
+ } catch (e) {
92
+ console.error(`claude-bridge: leaving ${path} alone, it does not parse: ${e}`);
93
+ return path;
94
+ }
95
+ }
96
+ // Match startupNoticeShown's local YYYY-MM-DD date format.
97
+ const next = { ...existing, askClaudeDeprecationShown: new Date().toLocaleDateString("en-CA") };
98
+ mkdirSync(dirname(path), { recursive: true });
99
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
100
+ return path;
101
+ }
102
+
103
+ export function loadConfig(_cwd: string): Config {
104
+ return tryParseJson(globalConfigPath());
105
+ }
@@ -0,0 +1,222 @@
1
+ // Pure pi→Anthropic message conversion helpers.
2
+ // Extracted so they can be tested without pulling in the full extension runtime.
3
+
4
+ import type { Message as PiMessage } from "@earendil-works/pi-ai";
5
+ import type { Message as SessionMessage } from "cc-session-io";
6
+ import { pascalCase } from "change-case";
7
+ import { MCP_TOOL_PREFIX } from "./skills.js";
8
+
9
+ export const PROVIDER_ID = "claude-bridge";
10
+
11
+ // Pi tool names under Claude Code's builtin names. Only ever correct on the
12
+ // AskClaude path, where CC runs its own tools — see mapPiToolNameToSdk.
13
+ export const PI_TO_SDK_TOOL_NAME: Record<string, string> = {
14
+ read: "Read", write: "Write", edit: "Edit", bash: "Bash",
15
+ };
16
+
17
+ export function sanitizeToolId(id: string, cache: Map<string, string>): string {
18
+ const existing = cache.get(id);
19
+ if (existing) return existing;
20
+ const clean = id.replace(/[^a-zA-Z0-9_-]/g, "_");
21
+ cache.set(id, clean);
22
+ return clean;
23
+ }
24
+
25
+ /** A pi tool name as the name a rebuilt transcript has to call it by.
26
+ *
27
+ * Whether a map is passed is what distinguishes the two query shapes, because
28
+ * they need opposite answers:
29
+ *
30
+ * - **With a map — the provider path.** The query runs `tools: []`, so every
31
+ * tool Claude can call is a pi tool served over MCP, and its name is
32
+ * `mcp__custom-tools__<pi name>` by construction (resolveMcpTools). The map
33
+ * is consulted first only because it carries the served tool's exact casing.
34
+ * A name it lacks is a tool pi ran that we do not serve now — AskClaude,
35
+ * excluded on purpose, or an extension since disabled — and naming that after
36
+ * a Claude Code builtin would tell the model a builtin it cannot call is
37
+ * available and was already used. That is the prompt condition behind the
38
+ * phantom-call deadlock fixed in 122914dd, and the read direction refuses the
39
+ * same names for the same reason (piToolNameFor in index.ts).
40
+ * - **Without a map — the AskClaude path.** CC runs its own tools there, so
41
+ * builtin names are real, matching mapToolName in the other direction.
42
+ */
43
+ export function mapPiToolNameToSdk(name: string, customToolNameToSdk?: Map<string, string>): string {
44
+ if (!name) return "";
45
+ const normalized = name.toLowerCase();
46
+ // Pi history holds pi tool names. Our own SDK prefix can only reach here by
47
+ // feeding already-converted names back through the conversion, and prefixing
48
+ // twice invents a tool nobody serves.
49
+ if (normalized.startsWith(MCP_TOOL_PREFIX)) {
50
+ throw new Error(`mapPiToolNameToSdk: "${name}" is already an SDK tool name — pi history holds pi tool names`);
51
+ }
52
+ if (!customToolNameToSdk) return PI_TO_SDK_TOOL_NAME[normalized] ?? pascalCase(name);
53
+ return customToolNameToSdk.get(name) ?? customToolNameToSdk.get(normalized) ?? `${MCP_TOOL_PREFIX}${name}`;
54
+ }
55
+
56
+ export function messageContentToText(
57
+ content: string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
58
+ ): string {
59
+ if (typeof content === "string") return content;
60
+ if (!Array.isArray(content)) return "";
61
+ const parts = [];
62
+ let hasText = false;
63
+ for (const block of content) {
64
+ if (block.type === "text" && block.text) { parts.push(block.text); hasText = true; }
65
+ else if (block.type !== "text" && block.type !== "image") { parts.push(`[${block.type}]`); }
66
+ }
67
+ return hasText ? parts.join("\n") : "";
68
+ }
69
+
70
+ // Tool results are flattened to text, which is how Claude Code stores most of
71
+ // them. Images are the exception: they have no text form, so a result carrying
72
+ // one keeps the block array shape instead (also what CC writes for screenshots).
73
+ function toolResultContent(
74
+ content: string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
75
+ ): string | Array<Record<string, unknown>> {
76
+ if (typeof content === "string" || !Array.isArray(content)) return messageContentToText(content) || "";
77
+ const images = content.filter((b) => b.type === "image" && b.data && b.mimeType);
78
+ if (!images.length) return messageContentToText(content) || "";
79
+ const blocks: Array<Record<string, unknown>> = [];
80
+ for (const block of content) {
81
+ if (block.type === "text" && block.text) blocks.push({ type: "text", text: block.text });
82
+ else if (block.type === "image" && block.data && block.mimeType) {
83
+ blocks.push({ type: "image", source: { type: "base64", media_type: block.mimeType, data: block.data } });
84
+ } else if (block.type !== "text" && block.type !== "image") {
85
+ // Same marker messageContentToText leaves for unrecognized blocks, so the
86
+ // text and image paths describe an extension's output the same way.
87
+ blocks.push({ type: "text", text: `[${block.type}]` });
88
+ }
89
+ }
90
+ return blocks;
91
+ }
92
+
93
+ /** What convertPiMessages discarded, for the debug line in index.ts. */
94
+ export type DroppedContent = {
95
+ thinking: number;
96
+ abortedTurns: number;
97
+ providers: Set<string>;
98
+ other: Map<string, number>;
99
+ };
100
+
101
+ /** Convert pi message array to Anthropic API format. */
102
+ export function convertPiMessages(
103
+ messages: PiMessage[],
104
+ customToolNameToSdk?: Map<string, string>,
105
+ ): { anthropicMessages: SessionMessage[]; sanitizedIds: Map<string, string>; dropped: DroppedContent } {
106
+ const anthropicMessages = [];
107
+ const sanitizedIds = new Map();
108
+ // What conversion discarded. Nothing downstream can tell: a stripped thinking
109
+ // block and a message that never carried one convert to the same thing, so
110
+ // without this the loss is invisible in the log and in a captured request.
111
+ const dropped: DroppedContent = { thinking: 0, abortedTurns: 0, providers: new Set(), other: new Map() };
112
+ // The user message collecting this assistant turn's tool results, if one has
113
+ // been emitted yet, and the index of the assistant message it belongs to. Both
114
+ // are cleared at every assistant message — see the toolResult branch.
115
+ let turnResults: { role: "user"; content: Array<Record<string, unknown>> } | null = null;
116
+ let turnAssistantIdx: number | null = null;
117
+
118
+ for (const msg of messages) {
119
+ if (msg.role === "user") {
120
+ if (typeof msg.content === "string") {
121
+ anthropicMessages.push({ role: "user", content: msg.content || "[empty]" });
122
+ } else if (Array.isArray(msg.content)) {
123
+ const parts = [];
124
+ for (const block of msg.content) {
125
+ if (block.type === "text" && block.text) parts.push({ type: "text", text: block.text });
126
+ else if (block.type === "image" && block.data && block.mimeType) {
127
+ parts.push({ type: "image", source: { type: "base64", media_type: block.mimeType, data: block.data } });
128
+ }
129
+ }
130
+ anthropicMessages.push({ role: "user", content: parts.length ? parts : "[image]" });
131
+ } else {
132
+ anthropicMessages.push({ role: "user", content: "[empty]" });
133
+ }
134
+ } else if (msg.role === "assistant") {
135
+ const content = Array.isArray(msg.content) ? msg.content : [];
136
+ const blocks = [];
137
+ for (const block of content) {
138
+ if (block.type === "text" && block.text) {
139
+ blocks.push({ type: "text", text: block.text });
140
+ } else if (block.type === "thinking") {
141
+ // Only replay thinking Claude Code itself produced. A signature minted
142
+ // by any other provider — including pi's own Anthropic provider — is
143
+ // not ours to hand back, and Anthropic rejects ones it can't verify.
144
+ const sig = block.thinkingSignature;
145
+ if (msg.provider === PROVIDER_ID && sig) {
146
+ blocks.push({ type: "thinking", thinking: block.thinking ?? "", signature: sig });
147
+ } else {
148
+ dropped.thinking++;
149
+ dropped.providers.add(msg.provider ?? "unknown");
150
+ }
151
+ } else if (block.type === "toolCall") {
152
+ const toolName = mapPiToolNameToSdk(block.name, customToolNameToSdk);
153
+ blocks.push({ type: "tool_use", id: sanitizeToolId(block.id, sanitizedIds), name: toolName, input: block.arguments ?? {} });
154
+ } else {
155
+ dropped.other.set(block.type, (dropped.other.get(block.type) ?? 0) + 1);
156
+ }
157
+ }
158
+ // A turn the user aborted before anything streamed carries no content at
159
+ // all. Standing a placeholder in its place invents a reply the assistant
160
+ // never made, and because it lands early in the prefix it costs the whole
161
+ // downstream prompt cache every time the session is rebuilt. Drop it:
162
+ // Session.importMessages imposes no alternation, and a turn with no blocks
163
+ // has no tool_use ids needing a synthetic result. Left before the turn
164
+ // bookkeeping so a stray result still attaches to the last assistant
165
+ // message actually emitted.
166
+ //
167
+ // Do NOT clear turnResults/turnAssistantIdx here. It looks like the tidy
168
+ // thing to do, but an abort between two parallel results — assistant[X,Y],
169
+ // R_X, aborted turn, R_Y — would then start a second results message for
170
+ // R_Y. repairToolPairing consumes both pending ids at the first one, stubs
171
+ // Y there and drops the real R_Y as unmatched, destroying the parallel
172
+ // result this merge exists to preserve. unit-import.mjs pins the shape.
173
+ if (!content.length) { dropped.abortedTurns++; continue; }
174
+ // Blocks were present but every one was filtered — content really was
175
+ // dropped here, so keep the slot and say so. Empty content is rejected by
176
+ // the API, and dropping the message would break tool pairing.
177
+ if (!blocks.length) blocks.push({ type: "text", text: "[incompatible content omitted]" });
178
+ turnResults = null;
179
+ turnAssistantIdx = anthropicMessages.length;
180
+ anthropicMessages.push({ role: "assistant", content: blocks });
181
+ } else if (msg.role === "toolResult") {
182
+ // Pi records one message per tool result, and repairToolPairing only
183
+ // pairs results that share the user message directly after their
184
+ // assistant message. Split across messages, the second and later results
185
+ // match no pending tool_use id: they are dropped and replaced with a
186
+ // synthetic "[no tool result recorded]", so every rebuild silently
187
+ // destroyed the output of parallel tool calls. Session.importMessages
188
+ // applies the repair itself, so this cannot be opted out of by skipping
189
+ // our own call. (Claude Code's live writer splits a turn across records
190
+ // — one per content block, one per result — so the single-message shape
191
+ // is repairToolPairing's requirement, not a copy of CC's own layout;
192
+ // tests/int-cc-contracts.mjs pins both facts.)
193
+ //
194
+ // Collecting into the turn's first result message rather than the
195
+ // immediately preceding one also handles a steer landing mid-execution,
196
+ // which pi records between the results (see extractAllToolResults).
197
+ // The results also have to sit *directly* after their assistant message:
198
+ // repairToolPairing consumes the turn's pending ids at the first user
199
+ // message that follows it, so a steer arriving before the first result —
200
+ // what any steer during a slow first tool looks like — would otherwise
201
+ // take the stubs and strand every real result behind it.
202
+ //
203
+ // Both hoists reorder the steer against wall-clock: Claude sees results
204
+ // that were still running when the steer arrived. Claude Code normalizes
205
+ // to the same order — it records a mid-turn steer as an `attachment`, and
206
+ // reorderAttachmentsForAPI (claude-code-rip src/utils/messages.ts:1481)
207
+ // bubbles attachments up to the nearest assistant or tool_result message
208
+ // and re-inserts them after it. The on-disk form differs, the order does not.
209
+ const block = { type: "tool_result", tool_use_id: sanitizeToolId(msg.toolCallId, sanitizedIds), content: toolResultContent(msg.content), is_error: msg.isError };
210
+ if (turnResults) {
211
+ turnResults.content.push(block);
212
+ } else {
213
+ turnResults = { role: "user", content: [block] };
214
+ // A result with no assistant message before it is malformed history;
215
+ // appending keeps it in order for repairToolPairing to discard.
216
+ anthropicMessages.splice(turnAssistantIdx === null ? anthropicMessages.length : turnAssistantIdx + 1, 0, turnResults);
217
+ }
218
+ }
219
+ }
220
+
221
+ return { anthropicMessages, sanitizedIds, dropped };
222
+ }
@@ -0,0 +1,47 @@
1
+ // Tool-result extraction: walks the context tail to collect this turn's
2
+ // tool results. Pi appends results to context and calls the provider again;
3
+ // this scrapes them back out. Walks past user messages (steer/followUp) that
4
+ // pi may inject between toolResults. Stops at the nearest assistant message
5
+ // (turn boundary).
6
+ // Extracted from index.ts so tests can import without activating the extension.
7
+
8
+ export type McpContent = Array<
9
+ | { type: "text"; text: string }
10
+ | { type: "image"; data: string; mimeType: string }
11
+ >;
12
+
13
+ export interface McpResult {
14
+ content: McpContent;
15
+ isError?: boolean;
16
+ toolCallId?: string;
17
+ [key: string]: unknown;
18
+ }
19
+
20
+ export function toolResultToMcpContent(
21
+ content: string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>,
22
+ ): McpContent {
23
+ if (typeof content === "string") return [{ type: "text", text: content || "" }];
24
+ if (!Array.isArray(content)) return [{ type: "text", text: "" }];
25
+ const blocks: McpContent = [];
26
+ for (const block of content) {
27
+ if (block.type === "text" && block.text) blocks.push({ type: "text", text: block.text });
28
+ else if (block.type === "image" && block.data && block.mimeType) blocks.push({ type: "image", data: block.data, mimeType: block.mimeType });
29
+ }
30
+ return blocks.length ? blocks : [{ type: "text", text: "" }];
31
+ }
32
+
33
+ // Returns { results, stopIdx } so callers can log the walk boundary.
34
+ export function extractAllToolResults(
35
+ messages: Array<{ role: string; content?: unknown; toolCallId?: string; isError?: boolean; [key: string]: unknown }>,
36
+ ): { results: McpResult[]; stopIdx: number } {
37
+ const results: McpResult[] = [];
38
+ let stopIdx = -1;
39
+ for (let i = messages.length - 1; i >= 0; i--) {
40
+ const msg = messages[i];
41
+ if (msg.role === "toolResult") {
42
+ results.unshift({ content: toolResultToMcpContent(msg.content as string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>), isError: msg.isError, toolCallId: msg.toolCallId });
43
+ } else if (msg.role === "assistant") { stopIdx = i; break; }
44
+ // user messages: skip (steer/followUp injected mid-tool-execution)
45
+ }
46
+ return { results, stopIdx };
47
+ }