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

175 lines
5.1 KiB
TypeScript

/**
* Codex adapter. Sessions live at
* `~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl`, partitioned by date
* rather than project, so we read each file's `session_meta.cwd` and group by
* it ourselves. We use the clean `event_msg` layer (user_message / agent_message)
* for the transcript and skip the lower-level response items, reasoning, and
* sub-agent (`thread_source: "subagent"`) sessions.
*/
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 CodexLine {
timestamp?: string;
type?: string;
payload?: Record<string, unknown>;
}
function readMeta(lines: CodexLine[]): {
cwd: string;
id: string;
isSubagent: boolean;
} {
const meta = lines.find((l) => l.type === "session_meta")?.payload ?? {};
return {
cwd: typeof meta.cwd === "string" ? meta.cwd : "",
id: typeof meta.id === "string" ? meta.id : "",
isSubagent: meta.thread_source === "subagent",
};
}
function eventMessage(line: CodexLine): NormalizedMessage | null {
if (line.type !== "event_msg") return null;
const p = line.payload ?? {};
let role: "user" | "assistant";
if (p.type === "user_message") role = "user";
else if (p.type === "agent_message") role = "assistant";
else return null;
const content = cleanText(typeof p.message === "string" ? p.message : "");
if (!content) return null;
const created = isoToEpochSeconds(line.timestamp, 0);
return { role, content, created_at: created || undefined };
}
/** A scanned file plus the `YYYY-MM-DD` recovered from its directory trail. */
interface CodexFile {
handle: FileSystemFileHandle;
date: string;
}
/** Turn a `sessions/2026/06/14` directory trail into `2026-06-14`. */
function dateFromTrail(trail: string[]): string {
const nums = trail.filter((seg) => /^\d+$/.test(seg));
if (nums.length < 3) return "";
const [y, m, d] = nums;
return `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`;
}
async function walkJsonl(
dir: FileSystemDirectoryHandle,
out: CodexFile[],
trail: string[] = [],
): Promise<void> {
for await (const entry of dir.values()) {
if (entry.kind === "directory") {
await walkJsonl(entry as FileSystemDirectoryHandle, out, [
...trail,
entry.name,
]);
} else if (entry.name.endsWith(".jsonl")) {
out.push({
handle: entry as FileSystemFileHandle,
date: dateFromTrail(trail),
});
}
}
}
export async function scanCodex(
root: FileSystemDirectoryHandle,
): Promise<ProjectGroup[]> {
const sessionsDir = await root.getDirectoryHandle("sessions");
const files: CodexFile[] = [];
await walkJsonl(sessionsDir, files);
const byCwd = new Map<string, SessionRef[]>();
for (const { handle, date } of files) {
const file = await handle.getFile();
const head = parseJsonl(
await readHead(file, SCAN_HEAD_BYTES),
) as CodexLine[];
const meta = readMeta(head);
if (meta.isSubagent) continue;
const cwd = meta.cwd || "(unknown)";
const firstUser = head.map(eventMessage).find((m) => m?.role === "user");
const ref: SessionRef = {
externalId: meta.id || handle.name.replace(/\.jsonl$/, ""),
provisionalTitle: firstUser ? deriveTitle(firstUser.content) : "",
cwd,
date: date || epochMsToISODate(file.lastModified),
lastModified: file.lastModified,
sizeBytes: file.size,
handle,
};
const arr = byCwd.get(cwd) ?? [];
arr.push(ref);
byCwd.set(cwd, arr);
}
const groups: ProjectGroup[] = [];
for (const [cwd, sessions] of byCwd) {
sessions.sort((a, b) => b.lastModified - a.lastModified);
groups.push({ cwd, label: projectLabel(cwd), sessions });
}
groups.sort(
(a, b) =>
(b.sessions[0]?.lastModified ?? 0) - (a.sessions[0]?.lastModified ?? 0),
);
return groups;
}
export async function parseCodexSession(
ref: SessionRef,
): Promise<NormalizedSession | null> {
const file = await ref.handle.getFile();
const messages: NormalizedMessage[] = [];
let cwd = ref.cwd;
let isSubagent = false;
for await (const line of iterLines(file)) {
let rec: CodexLine;
try {
rec = JSON.parse(line) as CodexLine;
} catch {
continue;
}
if (rec.type === "session_meta") {
const p = rec.payload ?? {};
if (typeof p.cwd === "string") cwd = p.cwd;
if (p.thread_source === "subagent") isSubagent = true;
continue;
}
const msg = eventMessage(rec);
if (msg) messages.push(msg);
}
if (isSubagent || !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: ref.provisionalTitle || deriveTitle(messages[0].content),
source_cwd: cwd,
created_at: firstTs,
updated_at: lastTs,
messages,
};
}