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.
- package/README.md +75 -5
- package/README.zh-CN.md +75 -5
- package/dist/web/assets/DesktopView-rVxacveF.js +5 -0
- package/dist/web/assets/index-B-8ISOr1.js +267 -0
- package/dist/web/assets/index-Cu1Pf8yX.css +1 -0
- package/dist/web/assets/{math-zZ4JA8eM.js → math-weKGDUmL.js} +2 -2
- package/dist/web/index.html +2 -2
- package/dist/web/mock/desktop.jpg +0 -0
- package/dist/web/mock/photo.jpg +0 -0
- package/dist/web/mock/photo2.jpg +0 -0
- package/dist/web/mock/sunset.html +90 -0
- 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
- package/package.json +10 -2
- package/server/src/actions.ts +24 -57
- package/server/src/assistant-skills.ts +201 -0
- package/server/src/boot.ts +1 -0
- package/server/src/claude-status.ts +47 -0
- package/server/src/config.ts +3 -1
- package/server/src/delegations.ts +40 -12
- package/server/src/desktop-control.ts +152 -0
- package/server/src/desktop-events.ts +63 -0
- package/server/src/desktop-public.ts +43 -0
- package/server/src/desktop-tools.ts +161 -0
- package/server/src/desktop.ts +76 -0
- package/server/src/embedding.ts +52 -0
- package/server/src/files.ts +63 -43
- package/server/src/http.ts +334 -15
- package/server/src/jobs.ts +330 -59
- package/server/src/knowledge-index.ts +123 -0
- package/server/src/knowledge-tools.ts +94 -0
- package/server/src/knowledge.ts +451 -0
- package/server/src/lore-entry.ts +10 -0
- package/server/src/main.ts +6 -0
- package/server/src/mcp.ts +745 -0
- package/server/src/memory.ts +11 -4
- package/server/src/now.ts +33 -0
- package/server/src/overview.ts +1 -1
- package/server/src/pages.ts +1 -1
- package/server/src/pi.ts +92 -16
- package/server/src/prompt.ts +46 -154
- package/server/src/providers.ts +3 -2
- package/server/src/push.ts +42 -7
- package/server/src/router-activity.ts +4 -0
- package/server/src/router-tools.ts +132 -38
- package/server/src/router.ts +159 -19
- package/server/src/routines.ts +14 -4
- package/server/src/runtime.ts +15 -0
- package/server/src/settings.ts +43 -1
- package/server/src/store.ts +4 -3
- package/server/src/thumbs.ts +49 -0
- package/server/src/transcript.ts +57 -4
- package/server/src/worker-tools.ts +14 -12
- package/shared/knowledge.ts +92 -0
- package/shared/mcp.ts +91 -0
- package/shared/media.ts +6 -0
- package/shared/plain-text.ts +31 -0
- package/shared/skills.ts +18 -0
- package/shared/sprites.ts +198 -0
- package/shared/types.ts +105 -20
- package/vendor/claude-bridge/HOB-PATCH.md +10 -0
- package/vendor/claude-bridge/LICENSE +21 -0
- package/vendor/claude-bridge/package.json +62 -0
- package/vendor/claude-bridge/src/agents-md.ts +14 -0
- package/vendor/claude-bridge/src/askclaude-schema.ts +92 -0
- package/vendor/claude-bridge/src/askclaude-ui.ts +90 -0
- package/vendor/claude-bridge/src/attachments.ts +135 -0
- package/vendor/claude-bridge/src/config.ts +105 -0
- package/vendor/claude-bridge/src/convert.ts +222 -0
- package/vendor/claude-bridge/src/extract-tool-results.ts +47 -0
- package/vendor/claude-bridge/src/index.ts +2503 -0
- package/vendor/claude-bridge/src/log-paths.ts +19 -0
- package/vendor/claude-bridge/src/mcp-server.ts +169 -0
- package/vendor/claude-bridge/src/models.ts +164 -0
- package/vendor/claude-bridge/src/prompt-capture.ts +420 -0
- package/vendor/claude-bridge/src/prompt-stream.ts +102 -0
- package/vendor/claude-bridge/src/query-state.ts +118 -0
- package/vendor/claude-bridge/src/rate-limit.ts +48 -0
- package/vendor/claude-bridge/src/session-cwd.ts +38 -0
- package/vendor/claude-bridge/src/session-verify.ts +38 -0
- package/vendor/claude-bridge/src/skills.ts +20 -0
- package/vendor/claude-bridge/src/transcript.ts +86 -0
- package/vendor/claude-bridge/src/usage.ts +53 -0
- package/vendor/lore-pi-extension/package.json +1 -1
- package/vendor/lore-pi-extension/tools.ts +8 -5
- package/dist/web/assets/index-C72gAwgN.css +0 -1
- 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
|
+
}
|