Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

174 lines
5.4 KiB
TypeScript

/**
* Claude Code adapter. Sessions live at
* `~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl`, already organized by
* project. Each line is one event; we keep only human-readable user/assistant
* text and drop tool calls, tool results, thinking, images, and sub-agent
* (`isSidechain`) branches so the import is a clean conversation.
*/
import { iterLines, parseJsonl, readHead } from "./streaming";
import {
cleanText,
deriveTitle,
epochMsToISODate,
isoToEpochSeconds,
projectLabel,
} from "./shared";
import type {
NormalizedMessage,
NormalizedSession,
ProjectGroup,
SessionRef,
} from "./types";
const SCAN_HEAD_BYTES = 64 * 1024;
interface ClaudeRecord {
type?: string;
timestamp?: string;
cwd?: string;
isSidechain?: boolean;
isMeta?: boolean;
aiTitle?: string;
message?: { role?: string; content?: unknown };
}
/** Lossy fallback: `/`, `.`, and literal `-` all encode to `-`, so this is only
* used as a cwd/label hint when no record carries the real `cwd`. */
function decodeProjectDir(name: string): string {
return name.replace(/-/g, "/");
}
function flattenContent(content: unknown): {
text: string;
hadImages: boolean;
} {
if (typeof content === "string") return { text: content, hadImages: false };
if (!Array.isArray(content)) return { text: "", hadImages: false };
const parts: string[] = [];
let hadImages = false;
for (const block of content) {
if (!block || typeof block !== "object") continue;
const b = block as Record<string, unknown>;
if (b.type === "text" && typeof b.text === "string") parts.push(b.text);
else if (b.type === "image") hadImages = true;
// tool_use / tool_result / thinking are intentionally dropped.
}
return { text: parts.join("\n").trim(), hadImages };
}
function toMessage(rec: ClaudeRecord): NormalizedMessage | null {
// Sub-agent branches and harness-injected meta turns aren't human dialogue.
if (rec.isSidechain || rec.isMeta) return null;
const role = rec.message?.role;
if (role !== "user" && role !== "assistant") return null;
const { text, hadImages } = flattenContent(rec.message?.content);
const content = cleanText(text);
if (!content) return null;
const created = isoToEpochSeconds(rec.timestamp, 0);
return {
role,
content,
created_at: created || undefined,
metadata: hadImages ? { had_images: true } : undefined,
};
}
export async function scanClaude(
root: FileSystemDirectoryHandle,
): Promise<ProjectGroup[]> {
const projectsDir = await root.getDirectoryHandle("projects");
const groups: ProjectGroup[] = [];
for await (const entry of projectsDir.values()) {
if (entry.kind !== "directory") continue;
const dir = entry as FileSystemDirectoryHandle;
const sessions: SessionRef[] = [];
for await (const fileEntry of dir.values()) {
if (fileEntry.kind !== "file" || !fileEntry.name.endsWith(".jsonl")) {
continue;
}
const handle = fileEntry as FileSystemFileHandle;
const file = await handle.getFile();
const head = parseJsonl(
await readHead(file, SCAN_HEAD_BYTES),
) as ClaudeRecord[];
const cwd =
head.find((r) => typeof r.cwd === "string")?.cwd ??
decodeProjectDir(dir.name);
const aiTitle = head.find(
(r) => r.type === "ai-title" && r.aiTitle,
)?.aiTitle;
const firstUser = head.map(toMessage).find((m) => m?.role === "user");
sessions.push({
externalId: handle.name.replace(/\.jsonl$/, ""),
provisionalTitle:
aiTitle || (firstUser ? deriveTitle(firstUser.content) : ""),
cwd,
date: epochMsToISODate(file.lastModified),
lastModified: file.lastModified,
sizeBytes: file.size,
handle,
});
}
if (!sessions.length) continue;
sessions.sort((a, b) => b.lastModified - a.lastModified);
// Pin every session to the group's resolved cwd so the project key the
// scope filter matches on is identical to each session's `cwd`.
const groupCwd = sessions[0].cwd;
for (const s of sessions) s.cwd = groupCwd;
groups.push({
cwd: groupCwd,
label: projectLabel(groupCwd),
sessions,
});
}
groups.sort(
(a, b) =>
(b.sessions[0]?.lastModified ?? 0) - (a.sessions[0]?.lastModified ?? 0),
);
return groups;
}
export async function parseClaudeSession(
ref: SessionRef,
): Promise<NormalizedSession | null> {
const file = await ref.handle.getFile();
const messages: NormalizedMessage[] = [];
let cwd = ref.cwd;
let aiTitle = "";
for await (const line of iterLines(file)) {
let rec: ClaudeRecord;
try {
rec = JSON.parse(line) as ClaudeRecord;
} catch {
continue;
}
if (rec.type === "ai-title" && typeof rec.aiTitle === "string") {
aiTitle = rec.aiTitle;
continue;
}
if (!cwd && typeof rec.cwd === "string") cwd = rec.cwd;
const msg = toMessage(rec);
if (msg) messages.push(msg);
}
if (!messages.length) return null;
const fallbackTs = file.lastModified / 1000;
const firstTs = messages.find((m) => m.created_at)?.created_at ?? fallbackTs;
const lastTs =
[...messages].reverse().find((m) => m.created_at)?.created_at ?? fallbackTs;
return {
external_id: ref.externalId,
title: aiTitle || ref.provisionalTitle || deriveTitle(messages[0].content),
source_cwd: cwd,
created_at: firstTs,
updated_at: lastTs,
messages,
};
}