From 1c7af330805a1b023462f31d0b9a2a674b0edea7 Mon Sep 17 00:00:00 2001 From: Takashi Matsuyama Date: Mon, 19 Oct 2026 17:42:55 +0900 Subject: [PATCH] basou: Keep the order of imported Claude Code transcripts when their birth times change The import groups the transcripts of a conversation and lets the one whose file was created first keep the shared records. Birth times are read on every import, so when they changed after an import (a restore from a backup, a copy to another disk) and the order flipped, the session holding the shared records was re-derived without them, and the fork, whose session begins after its copies, did not take them back: they were counted in no session. The same happened between two forks when the earlier one grew after a later one had copied from it. The transcripts already imported now keep the order in which their sessions begin, in the places they hold by birth time. A transcript not imported yet is still placed by its birth time, unless it holds a copy of a record an imported fork's session counts: then it forked from that fork and goes after it, so a restore cannot place it where it would take that fork's records again. The session store is loaded before the conversations are indexed, still once per run. The spec and the changelog name the cases the order still cannot recover from the files. --- CHANGELOG.md | 14 +- docs/spec/terminal-and-import.md | 33 ++- packages/cli/src/commands/import.test.ts | 280 +++++++++++++++++++++++ packages/cli/src/commands/import.ts | 173 ++++++++++---- 4 files changed, 449 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab10457..6989801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,9 +34,17 @@ All notable changes to **basou** are recorded here. The project follows is deleted. The active time `basou stats` bills and the board's active time are unions of time, which the copies overlapped, so they hardly change; the per-session sums of active time and the output tokens do. The - order is read from the files' birth times on every import, so if they - change after an import (restored from a backup, or copied to another disk) - and the order flips, the shared records end up counted in neither session. + birth times are read from the files on every import and can change after + one (restored from a backup, or copied to another disk), so the + transcripts already imported keep the order in which their sessions begin, + and a transcript not imported yet that holds a copy of what an imported + fork's session counts comes after that fork. Three cases are left, which + `docs/spec/terminal-and-import.md` describes: two sessions an earlier + basou imported with the shared records are still ordered by birth time + (counted once either way); a fork not imported yet whose birth time + becomes earlier than its imported original's takes the shared records from + it; and an original never imported whose birth time becomes later than its + fork's is imported without them, so they count in neither session. - **A key no version of its kind takes, in a measure or an observation of a board, is refused with the keys the kind takes**, in the version the board is read by: diff --git a/docs/spec/terminal-and-import.md b/docs/spec/terminal-and-import.md index 7bceef2..93aa9cc 100644 --- a/docs/spec/terminal-and-import.md +++ b/docs/spec/terminal-and-import.md @@ -155,12 +155,33 @@ transcript, so a fork's copies stay out after the older transcript is deleted. A fork imported with its copies whose older transcript was deleted before it could be re-derived cannot be told apart from an original, and keeps them. -The order within a conversation is read from the files on every import. If the -birth times change after an import (the transcripts are restored from a backup, -or copied to another disk) so that the order flips, the transcript now taken as -the later one is re-derived without the shared records, while the one now taken -as the earlier keeps the session it has, so the shared records are then counted -in neither session. +The birth times are read from the files on every import, and they can change +after one (the transcripts are restored from a backup, or copied to another +disk). The transcripts already imported therefore keep the order in which +their sessions begin: the session that took the shared records begins at or +before the conversation's first record, and a fork imported without them +begins after the last of them, so a later change of their birth times moves no +record from one session to another, between an original and its fork or +between two forks. A transcript not imported yet takes its place among them by +its birth time, except that one holding a copy of a record an imported fork's +session counts comes after that fork: it forked from that fork, or from a fork +of it. + +Three cases are left that the order cannot recover from the files: + +- Two sessions that begin together, as an earlier basou left a fork imported + with the shared records beside its original, are ordered by birth time. If + those flip, the original is re-derived without the shared records and the + fork keeps them; they are counted once either way. +- A fork not imported yet whose birth time becomes earlier than its imported + original's takes the shared records: the original's session is re-derived + without them, and the fork's session holds them. A `--session` import that + names only the fork counts them in both sessions until an import covers the + original again. +- An original that was never imported (for example, a `--session` import took + only its fork) and whose birth time becomes later than the fork's is + imported without the shared records, which the fork's session does not hold + either, so they are counted in neither session. ## §14.3 Multi-root source roots diff --git a/packages/cli/src/commands/import.test.ts b/packages/cli/src/commands/import.test.ts index bd5f2bf..4d45f09 100644 --- a/packages/cli/src/commands/import.test.ts +++ b/packages/cli/src/commands/import.test.ts @@ -1260,6 +1260,9 @@ describe("basou import claude-code — scoped re-import of a grown source", () = const ORIGINAL_ID = "zzzzzzzz-original"; const FORK_ID = "aaaaaaaa-fork"; const CREATED_AT_MS: Record = { [ORIGINAL_ID]: 1_000, [FORK_ID]: 2_000 }; +/** The same two files after a restore that gave the fork the earlier birth time. */ +const FLIPPED_CREATED_AT_MS: Record = { [ORIGINAL_ID]: 2_000, [FORK_ID]: 1_000 }; +const FORK_OF_FORK_ID = "bbbbbbbb-fork-of-fork"; function uuid(n: number): string { return `4a1f0c2e-0000-4000-8000-${String(n).padStart(12, "0")}`; @@ -1329,6 +1332,28 @@ function conversationStart(repo: string, sessionId: string): Array { + return { + type: "assistant", + uuid: uuid(n), + timestamp, + cwd: repo, + sessionId, + message: { + id: `msg_${n}`, + usage: { output_tokens: 10 }, + content: [{ type: "tool_use", name: "Bash", input: { command } }], + }, + }; +} + function originalTranscript(repo: string): Array> { return [ ...conversationStart(repo, ORIGINAL_ID), @@ -1376,6 +1401,16 @@ function forkTranscript(repo: string, extra: Array> = [] ]; } +/** A fork of the fork, taken after the fork's 01:00 command: it holds a copy of that too. */ +function forkOfForkTranscript(repo: string): Array> { + return [ + ...forkTranscript(repo) + .slice(1) + .map((record) => ({ ...record, sessionId: FORK_OF_FORK_ID })), + commandRecord(repo, 21, "2026-05-10T03:00:00.000Z", FORK_OF_FORK_ID, "pnpm lint"), + ]; +} + function forkCtx(repo: string, createdAtMs: Record = CREATED_AT_MS) { return { cwd: repo, @@ -1784,6 +1819,251 @@ describe("basou import claude-code: forked transcripts", () => { expect(result?.skipped_unverifiable).toBe(1); expect(await commandsOf(repo, fork)).toEqual(["pnpm build", "pnpm test"]); }); + + it("keeps the shared records where they are when the birth times flip after an import", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo)); + await writeTranscript(repo, FORK_ID, forkTranscript(repo)); + await doRunImportClaudeCode({ all: true }, forkCtx(repo)); + const original = await sessionIdOf(repo, ORIGINAL_ID); + const fork = await sessionIdOf(repo, FORK_ID); + const eventsOf = (sid: string) => + readFile(join(basouPaths(repo).sessions, sid, "events.jsonl"), "utf8"); + const originalBytes = await eventsOf(original); + const forkBytes = await eventsOf(fork); + + // Restored from a backup: the fork's file is now the older one. + const result = await importJson(repo, { all: true }, forkCtx(repo, FLIPPED_CREATED_AT_MS)); + expect(result?.reimported_count).toBe(0); + expect(result?.dropped_copies_count).toBe(0); + expect(result?.skipped_already_imported).toBe(2); + expect(await eventsOf(original)).toBe(originalBytes); + expect(await eventsOf(fork)).toBe(forkBytes); + }); + + it("re-imports a grown original without dropping the shared records after the birth times flip", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo)); + await writeTranscript(repo, FORK_ID, forkTranscript(repo)); + await doRunImportClaudeCode({ all: true }, forkCtx(repo)); + const original = await sessionIdOf(repo, ORIGINAL_ID); + + await writeTranscript(repo, ORIGINAL_ID, [ + ...originalTranscript(repo), + commandRecord(repo, 6, "2026-05-10T03:00:00.000Z", ORIGINAL_ID, "git diff"), + ]); + const result = await importJson(repo, { all: true }, forkCtx(repo, FLIPPED_CREATED_AT_MS)); + expect(result?.reimported_count).toBe(1); + expect(result?.dropped_copies_count).toBe(0); + expect(await commandsOf(repo, original)).toEqual(["git status", "git log", "git diff"]); + expect( + (await readEvents(repo, original)).filter((e) => e.type === "decision_recorded"), + ).toHaveLength(1); + expect((await readSession(repo, original)).session.started_at).toBe("2026-05-10T00:00:00.000Z"); + expect(await commandsOf(repo, await sessionIdOf(repo, FORK_ID))).toEqual(["pnpm build"]); + }); + + it("keeps what a later fork copied from a fork in that fork when their birth times flip", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo)); + await writeTranscript(repo, FORK_ID, forkTranscript(repo)); + // Forked from the fork after its 01:00 command, so it holds a copy of that too. + await writeTranscript(repo, FORK_OF_FORK_ID, forkOfForkTranscript(repo)); + await doRunImportClaudeCode( + { all: true }, + forkCtx(repo, { [ORIGINAL_ID]: 1_000, [FORK_ID]: 2_000, [FORK_OF_FORK_ID]: 3_000 }), + ); + const fork = await sessionIdOf(repo, FORK_ID); + const forkOfFork = await sessionIdOf(repo, FORK_OF_FORK_ID); + expect(await commandsOf(repo, forkOfFork)).toEqual(["pnpm lint"]); + + // The two forks' files flip, and the first fork goes on. + await writeTranscript( + repo, + FORK_ID, + forkTranscript(repo, [ + commandRecord(repo, 13, "2026-05-10T04:00:00.000Z", FORK_ID, "pnpm test"), + ]), + ); + const result = await importJson( + repo, + { all: true }, + forkCtx(repo, { [ORIGINAL_ID]: 1_000, [FORK_ID]: 3_000, [FORK_OF_FORK_ID]: 2_000 }), + ); + expect(result?.reimported_count).toBe(1); + expect(await commandsOf(repo, fork)).toEqual(["pnpm build", "pnpm test"]); + expect((await readSession(repo, fork)).session.started_at).toBe("2026-05-10T01:00:00.000Z"); + expect(await commandsOf(repo, forkOfFork)).toEqual(["pnpm lint"]); + expect(await commandsOf(repo, await sessionIdOf(repo, ORIGINAL_ID))).toEqual([ + "git status", + "git log", + ]); + }); + + it("--force re-creates the sessions in the order they began after the birth times flip", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo)); + await writeTranscript(repo, FORK_ID, forkTranscript(repo)); + await doRunImportClaudeCode({ all: true }, forkCtx(repo)); + + await doRunImportClaudeCode({ all: true, force: true }, forkCtx(repo, FLIPPED_CREATED_AT_MS)); + expect(await listSessionDirs(repo)).toHaveLength(2); + expect(await commandsOf(repo, await sessionIdOf(repo, ORIGINAL_ID))).toEqual([ + "git status", + "git log", + ]); + expect(await commandsOf(repo, await sessionIdOf(repo, FORK_ID))).toEqual(["pnpm build"]); + }); + + it("re-derives a fork imported with its copies beside an original imported with them too", async () => { + const repo = await setupInitedRepo(); + const fork = await importForkWithCopies(repo); + // The original is imported on its own, so both sessions begin at the + // conversation's first record, as an earlier basou left them. + await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo)); + await doRunImportClaudeCode({ session: ORIGINAL_ID }, forkCtx(repo)); + const original = await sessionIdOf(repo, ORIGINAL_ID); + expect(await commandsOf(repo, original)).toEqual(["git status", "git log"]); + expect(await commandsOf(repo, fork)).toEqual(["git status", "pnpm build"]); + + // They begin together, so their files' birth times decide. + const result = await importJson(repo, { all: true }); + expect(result?.reimported_count).toBe(1); + expect(result?.dropped_copies_count).toBe(1); + expect(await commandsOf(repo, original)).toEqual(["git status", "git log"]); + expect(await commandsOf(repo, fork)).toEqual(["pnpm build"]); + }); + + it("imports a new fork of an imported original with only what the fork added", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo)); + await doRunImportClaudeCode({ all: true }, forkCtx(repo)); + const original = await sessionIdOf(repo, ORIGINAL_ID); + const originalBytes = await readFile( + join(basouPaths(repo).sessions, original, "events.jsonl"), + "utf8", + ); + + await writeTranscript(repo, FORK_ID, forkTranscript(repo)); + const result = await importJson(repo, { all: true }); + expect(result?.imported_count).toBe(1); + expect(result?.reimported_count).toBe(0); + expect(await commandsOf(repo, await sessionIdOf(repo, FORK_ID))).toEqual(["pnpm build"]); + expect(await readFile(join(basouPaths(repo).sessions, original, "events.jsonl"), "utf8")).toBe( + originalBytes, + ); + }); + + it("places a transcript not imported yet by its birth time", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo)); + await writeTranscript(repo, FORK_ID, forkTranscript(repo)); + await doRunImportClaudeCode({ all: true }, forkCtx(repo)); + + // A new fork of the fork, after its 01:00 command. + await writeTranscript(repo, FORK_OF_FORK_ID, forkOfForkTranscript(repo)); + const result = await importJson( + repo, + { all: true }, + forkCtx(repo, { [ORIGINAL_ID]: 1_000, [FORK_ID]: 2_000, [FORK_OF_FORK_ID]: 3_000 }), + ); + expect(result?.imported_count).toBe(1); + expect(result?.skipped_already_imported).toBe(2); + expect(await commandsOf(repo, await sessionIdOf(repo, FORK_OF_FORK_ID))).toEqual(["pnpm lint"]); + }); + + it("places a transcript not imported yet after an imported fork it copied from, whatever its birth time", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo)); + await writeTranscript(repo, FORK_ID, forkTranscript(repo)); + await doRunImportClaudeCode({ all: true }, forkCtx(repo)); + const fork = await sessionIdOf(repo, FORK_ID); + await writeTranscript(repo, FORK_OF_FORK_ID, forkOfForkTranscript(repo)); + + // Restored in name order: the new transcript's birth time falls between + // the two imported ones, which keep the order their sessions began in. + const restored = { [FORK_ID]: 1_000, [FORK_OF_FORK_ID]: 2_000, [ORIGINAL_ID]: 3_000 }; + const result = await importJson(repo, { all: true }, forkCtx(repo, restored)); + expect(result?.imported_count).toBe(1); + expect(result?.reimported_count).toBe(0); + const forkOfFork = await sessionIdOf(repo, FORK_OF_FORK_ID); + expect(await commandsOf(repo, forkOfFork)).toEqual(["pnpm lint"]); + expect((await readSession(repo, forkOfFork)).session.started_at).toBe( + "2026-05-10T03:00:00.000Z", + ); + expect(await commandsOf(repo, fork)).toEqual(["pnpm build"]); + expect(await commandsOf(repo, await sessionIdOf(repo, ORIGINAL_ID))).toEqual([ + "git status", + "git log", + ]); + }); + + it("keeps the shared records with the original when a new fork of a fork is restored first", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, ORIGINAL_ID, originalTranscript(repo)); + await writeTranscript(repo, FORK_ID, forkTranscript(repo)); + await doRunImportClaudeCode({ all: true }, forkCtx(repo)); + await writeTranscript(repo, FORK_OF_FORK_ID, forkOfForkTranscript(repo)); + + const restored = { [FORK_OF_FORK_ID]: 1_000, [FORK_ID]: 2_000, [ORIGINAL_ID]: 3_000 }; + const result = await importJson(repo, { all: true }, forkCtx(repo, restored)); + expect(result?.imported_count).toBe(1); + expect(result?.reimported_count).toBe(0); + expect(await commandsOf(repo, await sessionIdOf(repo, ORIGINAL_ID))).toEqual([ + "git status", + "git log", + ]); + expect(await commandsOf(repo, await sessionIdOf(repo, FORK_ID))).toEqual(["pnpm build"]); + expect(await commandsOf(repo, await sessionIdOf(repo, FORK_OF_FORK_ID))).toEqual(["pnpm lint"]); + }); + + it("keeps an original not imported yet before an imported fork that it holds nothing of", async () => { + const repo = await setupInitedRepo(); + // The conversation is talk only until it forks, so the original is not + // imported, and the fork is imported with what it added. + const talk = (sessionId: string): Array> => [ + { + type: "user", + uuid: uuid(1), + timestamp: "2026-05-10T00:00:00.000Z", + cwd: repo, + sessionId, + message: { role: "user", content: [{ type: "text", text: "go" }] }, + }, + { + type: "assistant", + uuid: uuid(2), + timestamp: "2026-05-10T00:00:01.000Z", + cwd: repo, + sessionId, + message: { + id: "msg_1", + usage: { output_tokens: 999 }, + content: [{ type: "text", text: "ok" }], + }, + }, + ]; + await writeTranscript(repo, ORIGINAL_ID, talk(ORIGINAL_ID)); + await writeTranscript(repo, FORK_ID, [...talk(FORK_ID), ...forkTranscript(repo).slice(-2)]); + await doRunImportClaudeCode({ all: true }, forkCtx(repo)); + expect(await listSessionDirs(repo)).toHaveLength(1); + const fork = await sessionIdOf(repo, FORK_ID); + expect((await readSession(repo, fork)).session.started_at).toBe("2026-05-10T01:00:00.000Z"); + + // The original goes on and runs a command of its own. + await writeTranscript(repo, ORIGINAL_ID, [ + ...talk(ORIGINAL_ID), + commandRecord(repo, 6, "2026-05-10T02:00:00.000Z", ORIGINAL_ID, "git log"), + ]); + const result = await importJson(repo, { all: true }); + expect(result?.imported_count).toBe(1); + const original = await sessionIdOf(repo, ORIGINAL_ID); + expect(await commandsOf(repo, original)).toEqual(["git log"]); + const originalSession = await readSession(repo, original); + expect(originalSession.session.started_at).toBe("2026-05-10T00:00:00.000Z"); + expect(originalSession.session.metrics?.output_tokens).toBe(1009); + expect(await commandsOf(repo, fork)).toEqual(["pnpm build"]); + }); }); describe("basou import codex — scoped re-import of a grown source", () => { diff --git a/packages/cli/src/commands/import.ts b/packages/cli/src/commands/import.ts index f8d4cd5..0336d7f 100644 --- a/packages/cli/src/commands/import.ts +++ b/packages/cli/src/commands/import.ts @@ -85,8 +85,9 @@ export type ImportContext = { /** * When a Claude transcript's file was created, in milliseconds since the * epoch, which decides which of two transcripts of one conversation forked - * the other. Defaults to the file's birth time. Injectable for tests: a file - * system offers no way to set a birth time. + * the other, unless both were imported before and their sessions begin at + * different times. Defaults to the file's birth time. Injectable for tests: a + * file system offers no way to set a birth time. */ transcriptCreatedAtMs?: (file: string) => Promise; }; @@ -274,12 +275,14 @@ export async function doRunImportClaudeCode( const projectsRoot = ctx.claudeProjectsDir ?? join(homedir(), ".claude", "projects"); const files = await selectTranscriptFiles(projectsRoot, projectPaths, options); + const store = await loadImportStore(paths, options, CLAUDE_IMPORT_SOURCE); // Every transcript of the source roots, not only the selected ones: a // transcript forked from another opens with copies of its records, and which // records are copies is known only beside the older transcript. const conversations = await indexConversations( await listTranscriptFiles(projectsRoot, projectPaths), ctx.transcriptCreatedAtMs ?? birthTimeMs, + (externalId) => earliestStart(store.byExternalId.get(externalId) ?? []), ); // Claude Code's per-project directory name is lossy (every non-alphanumeric // char -> "-"), so distinct project paths can collide into one directory. @@ -338,7 +341,7 @@ export async function doRunImportClaudeCode( paths, manifest, options, - CLAUDE_IMPORT_SOURCE, + store, candidates, projectPaths, hasDeclaredBoundary(options, manifest), @@ -381,7 +384,7 @@ export async function doRunImportCodex( paths, manifest, options, - CODEX_IMPORT_SOURCE, + await loadImportStore(paths, options, CODEX_IMPORT_SOURCE), candidates, projectPaths, hasDeclaredBoundary(options, manifest), @@ -422,30 +425,21 @@ async function resolveImportTarget( * The vendor-neutral import core: dedup, derive, validate, and write each * candidate, then report. Every `basou import ` funnels its * discovered candidates through here, so dedup / `--force` / `--dry-run` - * semantics stay identical across adapters. Dedup is scoped to `sourceKind` - * so one adapter never matches (or, under `--force`, deletes) another - * adapter's session that happens to share an id string. + * semantics stay identical across adapters. Dedup is scoped to the + * `sourceKind` the store was loaded for, so one adapter never matches (or, + * under `--force`, deletes) another adapter's session that happens to share an + * id string. */ async function importDerivedSessions( paths: BasouPaths, manifest: Manifest, options: ImportOptions, - sourceKind: SessionSourceKind, + store: ImportStore, candidates: ReadonlyArray, projectPaths: ReadonlyArray, boundaryDeclared: boolean, ): Promise { - // A `.basou/sessions` that is a symlink stops the import before anything is - // read from it, `--dry-run` included. A re-import takes a session lock, so a - // `.basou/locks` that is one stops it too, before any session is imported - // (a dry run takes no lock and is not stopped). - await assertSessionStoreSafe(paths); - if (options.dryRun !== true) await assertLockStoreSafe(paths); - const { - byExternalId: existingByExternalId, - notDirectories, - yamlNotFiles, - } = await loadExistingByExternalId(paths, sourceKind); + const { byExternalId: existingByExternalId, notDirectories, yamlNotFiles } = store; // An entry named as a session that is not a directory is not followed, nor // is a session.yaml that is not a regular file, so an earlier import either // holds is not seen. Said once, when a session is about to be imported as @@ -824,14 +818,35 @@ function firstTranscriptCwd(records: ReadonlyArray): str */ type PriorImport = { sessionId: string; startedAt: string; sourceSizeBytes?: number }; -async function loadExistingByExternalId( - paths: BasouPaths, - sourceKind: SessionSourceKind, -): Promise<{ +/** What {@link loadExistingByExternalId} found in the session store. */ +type ImportStore = { byExternalId: Map; notDirectories: string[]; yamlNotFiles: string[]; -}> { +}; + +/** + * Check the stores an import reads and writes, then load the sessions earlier + * imports made from `sourceKind`. A `.basou/sessions` that is a symlink stops + * the import before anything is read from it, `--dry-run` included. A + * re-import takes a session lock, so a `.basou/locks` that is one stops it + * too, before any session is imported (a dry run takes no lock and is not + * stopped). + */ +async function loadImportStore( + paths: BasouPaths, + options: ImportOptions, + sourceKind: SessionSourceKind, +): Promise { + await assertSessionStoreSafe(paths); + if (options.dryRun !== true) await assertLockStoreSafe(paths); + return loadExistingByExternalId(paths, sourceKind); +} + +async function loadExistingByExternalId( + paths: BasouPaths, + sourceKind: SessionSourceKind, +): Promise { const byExternalId = new Map(); const add = (externalId: string, prior: PriorImport): void => { const list = byExternalId.get(externalId); @@ -957,8 +972,9 @@ type ConversationRoot = { uuid: string; timestamp?: string }; type Conversations = { /** * The root of `file`'s conversation, and the other transcripts of that - * conversation created before it, oldest first. A transcript with older ones - * forked from them and opens with copies of their records. + * conversation taken as older than it, oldest first (see + * {@link indexConversations}). A transcript with older ones forked from them + * and opens with copies of their records. */ place(file: string): { root: ConversationRoot | undefined; older: string[] }; /** Every uuid the records of `files` carry. Each file is read once per run. */ @@ -976,12 +992,37 @@ type Conversations = { * (`createdAtMs`, the file's birth time). Where a file system records no birth * time, or two are equal, the session id decides: an arbitrary order, but one * every run agrees on. + * + * Birth times can change after an import (the transcripts restored from a + * backup, or copied to another disk), and the sessions earlier imports made do + * not. So the transcripts imported before are put in the order their sessions + * begin (`importedStartedAt`), in the places they hold by birth time: the one + * that took the shared records begins at or before the conversation's first + * record, and a fork imported without them begins after the last of them, so a + * flip of their birth times moves no record from one session to another. Two + * sessions that begin together, as an earlier basou left a fork imported with + * the shared records beside its original, keep their birth order. + * + * A transcript not imported yet keeps its place by birth time, unless it holds + * a copy of a record an imported fork's session counts: then it forked from + * that fork, or from a fork of it, and goes after it. */ async function indexConversations( files: ReadonlyArray, createdAtMs: (file: string) => Promise, + importedStartedAt: (externalId: string) => string | undefined, ): Promise { type Entry = { file: string; externalId: string; createdAtMs: number }; + const uuidsByFile = new Map>(); + /** Every uuid the records of `file` carry, read once per run. */ + const uuidsIn = async (file: string): Promise> => { + let held = uuidsByFile.get(file); + if (held === undefined) { + held = await readTranscriptUuids(file); + uuidsByFile.set(file, held); + } + return held; + }; const rootOf = new Map(); const byRoot = new Map(); for (const file of files) { @@ -1003,8 +1044,48 @@ async function indexConversations( if (knownA !== knownB) return knownA ? -1 : 1; return a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0; }); + const startedAtMs = new Map(); + for (const entry of list) { + const ms = Date.parse(importedStartedAt(entry.externalId) ?? ""); + if (!Number.isNaN(ms)) startedAtMs.set(entry, ms); + } + // A stable sort: two sessions that begin together keep their birth order. + const imported = [...startedAtMs.keys()].sort( + (a, b) => (startedAtMs.get(a) ?? 0) - (startedAtMs.get(b) ?? 0), + ); + list.forEach((entry, index) => { + if (startedAtMs.has(entry)) list[index] = imported.shift() ?? entry; + }); + + // What a fork's session counts begins after the copies the fork opens + // with, so no transcript older than the fork holds any of it: one not + // imported yet that holds a copy of it forked from that fork, or from a + // fork of it, and goes after it. Read only for a transcript placed before + // such a fork. + const rootMs = Date.parse(rootOf.get(list[0]?.file ?? "")?.timestamp ?? ""); + const forks = list.filter((entry) => (startedAtMs.get(entry) ?? Number.NaN) > rootMs); + if (forks.length === 0) continue; + const rank = new Map(list.map((entry, index) => [entry, index] as const)); + const countedBy = new Map>(); + for (const [index, entry] of list.entries()) { + if (startedAtMs.has(entry)) continue; + for (const fork of forks) { + const forkRank = rank.get(fork) ?? index; + if (forkRank < index) continue; + let counted = countedBy.get(fork); + if (counted === undefined) { + counted = await readTranscriptUuids(fork.file, importedStartedAt(fork.externalId)); + countedBy.set(fork, counted); + } + if (counted.size === 0) continue; + const held = await uuidsIn(entry.file); + if ([...counted].some((uuid) => held.has(uuid))) { + rank.set(entry, Math.max(rank.get(entry) ?? index, forkRank + 0.5)); + } + } + } + list.sort((a, b) => (rank.get(a) ?? 0) - (rank.get(b) ?? 0)); } - const uuidsByFile = new Map>(); return { place(file) { const root = rootOf.get(file); @@ -1016,27 +1097,35 @@ async function indexConversations( async uuidsOf(older) { const uuids = new Set(); for (const file of older) { - let held = uuidsByFile.get(file); - if (held === undefined) { - held = new Set(); - try { - const { records } = await readJsonlRecords(file); - for (const record of records) { - const uuid = claudeTranscriptRecordUuid(record); - if (uuid !== undefined) held.add(uuid); - } - } catch { - // Gone since it was listed: nothing it held can be named as a copy. - } - uuidsByFile.set(file, held); - } - for (const uuid of held) uuids.add(uuid); + for (const uuid of await uuidsIn(file)) uuids.add(uuid); } return uuids; }, }; } +/** + * The uuids the records of a transcript carry; with `notBefore`, only those of + * the records {@link claudeTranscriptOwnRecords} keeps from that instant on, + * which are what a session that began then counts. Empty for a transcript gone + * since it was listed: nothing it held can be named as a copy. + */ +async function readTranscriptUuids(file: string, notBefore?: string): Promise> { + const uuids = new Set(); + try { + const { records } = await readJsonlRecords(file); + const kept = + notBefore === undefined ? records : claudeTranscriptOwnRecords(records, { notBefore }); + for (const record of kept) { + const uuid = claudeTranscriptRecordUuid(record); + if (uuid !== undefined) uuids.add(uuid); + } + } catch { + // Gone since it was listed. + } + return uuids; +} + /** * Read a transcript up to its first record that carries a uuid. `undefined` for * a transcript with none, or one that cannot be read.