refactor: split dense lines in binary lookup and transcript replay
This commit is contained in:
@@ -18,32 +18,51 @@ type SearchSpec = {
|
|||||||
function getWindowsSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec {
|
function getWindowsSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec {
|
||||||
const paths = path.win32;
|
const paths = path.win32;
|
||||||
const appData = env.APPDATA ?? paths.join(homeDir, "AppData", "Roaming");
|
const appData = env.APPDATA ?? paths.join(homeDir, "AppData", "Roaming");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paths,
|
paths,
|
||||||
names: ["claude.exe", "claude.cmd"],
|
names: ["claude.exe", "claude.cmd"],
|
||||||
knownDirs: [paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), env.npm_config_prefix ?? paths.join(appData, "npm")],
|
knownDirs: [
|
||||||
|
paths.join(homeDir, ".local", "bin"),
|
||||||
|
paths.join(homeDir, ".claude", "local"),
|
||||||
|
env.npm_config_prefix ?? paths.join(appData, "npm"),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPosixSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec {
|
function getPosixSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec {
|
||||||
const paths = path.posix;
|
const paths = path.posix;
|
||||||
const npmBin = env.npm_config_prefix ? paths.join(env.npm_config_prefix, "bin") : paths.join(homeDir, ".npm-global", "bin");
|
const npmBin = env.npm_config_prefix
|
||||||
|
? paths.join(env.npm_config_prefix, "bin")
|
||||||
|
: paths.join(homeDir, ".npm-global", "bin");
|
||||||
|
|
||||||
return {
|
return {
|
||||||
paths,
|
paths,
|
||||||
names: ["claude"],
|
names: ["claude"],
|
||||||
knownDirs: [paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), npmBin, "/opt/homebrew/bin", "/usr/local/bin"],
|
knownDirs: [
|
||||||
|
paths.join(homeDir, ".local", "bin"),
|
||||||
|
paths.join(homeDir, ".claude", "local"),
|
||||||
|
npmBin,
|
||||||
|
"/opt/homebrew/bin",
|
||||||
|
"/usr/local/bin",
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveClaudeBinary({ env, platform, homeDir, isExecutable }: BinaryLookup): string | null {
|
export function resolveClaudeBinary({ env, platform, homeDir, isExecutable }: BinaryLookup): string | null {
|
||||||
const { paths, names, knownDirs } = platform === "win32" ? getWindowsSearchSpec(env, homeDir) : getPosixSearchSpec(env, homeDir);
|
const { paths, names, knownDirs } = platform === "win32"
|
||||||
|
? getWindowsSearchSpec(env, homeDir)
|
||||||
|
: getPosixSearchSpec(env, homeDir);
|
||||||
|
|
||||||
const pathDirs = (env.PATH ?? "").split(paths.delimiter).filter(Boolean);
|
const pathDirs = (env.PATH ?? "").split(paths.delimiter).filter(Boolean);
|
||||||
|
|
||||||
for (const name of names) {
|
for (const name of names) {
|
||||||
for (const dir of [...pathDirs, ...knownDirs]) {
|
for (const dir of [...pathDirs, ...knownDirs]) {
|
||||||
const candidate = paths.join(dir, name);
|
const candidate = paths.join(dir, name);
|
||||||
if (isExecutable(candidate)) return candidate;
|
if (isExecutable(candidate)) return candidate;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,5 +76,10 @@ function isExecutableFile(filePath: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getClaudeBinary(): string | null {
|
export function getClaudeBinary(): string | null {
|
||||||
return resolveClaudeBinary({ env: process.env, platform: process.platform, homeDir: os.homedir(), isExecutable: isExecutableFile });
|
return resolveClaudeBinary({
|
||||||
|
env: process.env,
|
||||||
|
platform: process.platform,
|
||||||
|
homeDir: os.homedir(),
|
||||||
|
isExecutable: isExecutableFile,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { ImageContent, Message, TextContent, Tool } from "@earendil-works/pi-ai";
|
import type { ImageContent, Message, TextContent, Tool } from "@earendil-works/pi-ai";
|
||||||
import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
|
import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript";
|
||||||
|
|
||||||
export type ClaudeContentBlock = { type: "text"; text: string } | { type: "image"; source: { type: "base64"; media_type: string; data: string } };
|
export type ClaudeContentBlock =
|
||||||
|
| { type: "text"; text: string }
|
||||||
|
| { type: "image"; source: { type: "base64"; media_type: string; data: string } };
|
||||||
|
|
||||||
export type ClaudeInput = {
|
export type ClaudeInput = {
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
@@ -17,8 +19,10 @@ function toImageBlock(image: ImageContent): ClaudeContentBlock {
|
|||||||
|
|
||||||
function getLabelledParts(label: string, content: string | (TextContent | ImageContent)[]): ReplayPart[] {
|
function getLabelledParts(label: string, content: string | (TextContent | ImageContent)[]): ReplayPart[] {
|
||||||
if (typeof content === "string") return [`${label}\n${content}`];
|
if (typeof content === "string") return [`${label}\n${content}`];
|
||||||
|
|
||||||
const text = content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("\n");
|
const text = content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("\n");
|
||||||
const images = content.filter((block) => block.type === "image");
|
const images = content.filter((block) => block.type === "image");
|
||||||
|
|
||||||
return [text ? `${label}\n${text}` : label, ...images];
|
return [text ? `${label}\n${text}` : label, ...images];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,26 +30,33 @@ function getReplayParts(message: Message): ReplayPart[] {
|
|||||||
switch (message.role) {
|
switch (message.role) {
|
||||||
case "system":
|
case "system":
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
case "user":
|
case "user":
|
||||||
return getLabelledParts("[user]", message.content);
|
return getLabelledParts("[user]", message.content);
|
||||||
|
|
||||||
case "assistant":
|
case "assistant":
|
||||||
return message.content.flatMap((block) => {
|
return message.content.flatMap((block) => {
|
||||||
if (block.type === "text") return [`[assistant]\n${block.text}`];
|
if (block.type === "text") return [`[assistant]\n${block.text}`];
|
||||||
if (block.type === "toolCall") return [`[tool call ${block.name}] ${JSON.stringify(block.arguments)}`];
|
if (block.type === "toolCall") return [`[tool call ${block.name}] ${JSON.stringify(block.arguments)}`];
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
case "toolResult":
|
|
||||||
return getLabelledParts(message.isError ? `[tool result ${message.toolName} error]` : `[tool result ${message.toolName}]`, message.content);
|
case "toolResult": {
|
||||||
|
const label = message.isError ? `[tool result ${message.toolName} error]` : `[tool result ${message.toolName}]`;
|
||||||
|
return getLabelledParts(label, message.content);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toContentBlocks(parts: ReplayPart[]): ClaudeContentBlock[] {
|
function toContentBlocks(parts: ReplayPart[]): ClaudeContentBlock[] {
|
||||||
const blocks: ClaudeContentBlock[] = [];
|
const blocks: ClaudeContentBlock[] = [];
|
||||||
let pendingText: string[] = [];
|
let pendingText: string[] = [];
|
||||||
|
|
||||||
const flushText = () => {
|
const flushText = () => {
|
||||||
if (pendingText.length > 0) blocks.push({ type: "text", text: pendingText.join("\n\n") });
|
if (pendingText.length > 0) blocks.push({ type: "text", text: pendingText.join("\n\n") });
|
||||||
pendingText = [];
|
pendingText = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (typeof part === "string") {
|
if (typeof part === "string") {
|
||||||
pendingText.push(part);
|
pendingText.push(part);
|
||||||
@@ -54,20 +65,33 @@ function toContentBlocks(parts: ReplayPart[]): ClaudeContentBlock[] {
|
|||||||
blocks.push(toImageBlock(part));
|
blocks.push(toImageBlock(part));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
flushText();
|
flushText();
|
||||||
|
|
||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toUnlabelledBlocks(content: string | (TextContent | ImageContent)[]): ClaudeContentBlock[] {
|
function toUnlabelledBlocks(content: string | (TextContent | ImageContent)[]): ClaudeContentBlock[] {
|
||||||
if (typeof content === "string") return [{ type: "text", text: content }];
|
if (typeof content === "string") return [{ type: "text", text: content }];
|
||||||
|
|
||||||
return content.map((block) => (block.type === "text" ? { type: "text", text: block.text } : toImageBlock(block)));
|
return content.map((block) => (block.type === "text" ? { type: "text", text: block.text } : toImageBlock(block)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toUserContent(conversation: Message[]): ClaudeContentBlock[] {
|
||||||
|
const [first] = conversation;
|
||||||
|
if (conversation.length === 1 && first?.role === "user") return toUnlabelledBlocks(first.content);
|
||||||
|
|
||||||
|
return toContentBlocks(conversation.flatMap(getReplayParts));
|
||||||
|
}
|
||||||
|
|
||||||
export function toClaudeInput(messages: Message[]): ClaudeInput {
|
export function toClaudeInput(messages: Message[]): ClaudeInput {
|
||||||
const conversation = messages.filter((message) => message.role !== "system");
|
const conversation = messages.filter((message) => message.role !== "system");
|
||||||
const onlyMessage = conversation.length === 1 ? conversation[0] : null;
|
|
||||||
const userContent = onlyMessage?.role === "user" ? toUnlabelledBlocks(onlyMessage.content) : toContentBlocks(conversation.flatMap(getReplayParts));
|
return {
|
||||||
return { systemPrompt: getCurrentSystemPrompt(messages), tools: getCurrentTools(messages), userContent };
|
systemPrompt: getCurrentSystemPrompt(messages),
|
||||||
|
tools: getCurrentTools(messages),
|
||||||
|
userContent: toUserContent(conversation),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toStreamJsonLine(content: ClaudeContentBlock[]): string {
|
export function toStreamJsonLine(content: ClaudeContentBlock[]): string {
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ test("windows looks for claude.cmd and claude.exe", () => {
|
|||||||
assert.equal(resolveWith(["C:\\bin\\claude.cmd"], env, "win32", winHome), "C:\\bin\\claude.cmd");
|
assert.equal(resolveWith(["C:\\bin\\claude.cmd"], env, "win32", winHome), "C:\\bin\\claude.cmd");
|
||||||
assert.equal(resolveWith(["C:\\bin\\claude.exe"], env, "win32", winHome), "C:\\bin\\claude.exe");
|
assert.equal(resolveWith(["C:\\bin\\claude.exe"], env, "win32", winHome), "C:\\bin\\claude.exe");
|
||||||
assert.equal(resolveWith([`${winHome}\\.local\\bin\\claude.exe`], env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`);
|
assert.equal(resolveWith([`${winHome}\\.local\\bin\\claude.exe`], env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`);
|
||||||
assert.equal(resolveWith([`${winHome}\\AppData\\Roaming\\npm\\claude.cmd`], env, "win32", winHome), `${winHome}\\AppData\\Roaming\\npm\\claude.cmd`);
|
assert.equal(
|
||||||
|
resolveWith([`${winHome}\\AppData\\Roaming\\npm\\claude.cmd`], env, "win32", winHome),
|
||||||
|
`${winHome}\\AppData\\Roaming\\npm\\claude.cmd`,
|
||||||
|
);
|
||||||
assert.equal(resolveWith(["C:\\bin\\claude"], env, "win32", winHome), null);
|
assert.equal(resolveWith(["C:\\bin\\claude"], env, "win32", winHome), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,14 @@ function assistant(content: AssistantMessage["content"]): AssistantMessage {
|
|||||||
api: "claude-code-cli",
|
api: "claude-code-cli",
|
||||||
provider: "claude-code",
|
provider: "claude-code",
|
||||||
model: "opus",
|
model: "opus",
|
||||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
usage: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||||
|
},
|
||||||
stopReason: "stop",
|
stopReason: "stop",
|
||||||
timestamp: 0,
|
timestamp: 0,
|
||||||
};
|
};
|
||||||
@@ -96,7 +103,12 @@ test("error tool results are marked as errors in the replay", () => {
|
|||||||
toolResult("read", [{ type: "text", text: "ENOENT" }], true),
|
toolResult("read", [{ type: "text", text: "ENOENT" }], true),
|
||||||
user("why?"),
|
user("why?"),
|
||||||
];
|
];
|
||||||
const expected = ["[user]\nopen missing.md", '[tool call read] {"path":"missing.md"}', "[tool result read error]\nENOENT", "[user]\nwhy?"].join("\n\n");
|
const expected = [
|
||||||
|
"[user]\nopen missing.md",
|
||||||
|
'[tool call read] {"path":"missing.md"}',
|
||||||
|
"[tool result read error]\nENOENT",
|
||||||
|
"[user]\nwhy?",
|
||||||
|
].join("\n\n");
|
||||||
assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]);
|
assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -142,5 +154,8 @@ test("thinking blocks are omitted from replay", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('toStreamJsonLine emits {"type":"user","message":{"role":"user","content":[...]}}', () => {
|
test('toStreamJsonLine emits {"type":"user","message":{"role":"user","content":[...]}}', () => {
|
||||||
assert.deepEqual(JSON.parse(toStreamJsonLine([{ type: "text", text: "hi" }])), { type: "user", message: { role: "user", content: [{ type: "text", text: "hi" }] } });
|
assert.deepEqual(JSON.parse(toStreamJsonLine([{ type: "text", text: "hi" }])), {
|
||||||
|
type: "user",
|
||||||
|
message: { role: "user", content: [{ type: "text", text: "hi" }] },
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user