refactor(transcripts): store meeting captures in SQLite (#112910)

* refactor(transcripts): move meeting transcripts to sqlite

* perf(transcripts): batch legacy utterance staging

* fix(transcripts): report recovery moves on migration failure

* fix(transcripts): refresh archive membership after recovery

* fix(transcripts): omit failed summary export paths

* fix(transcripts): type restore metadata tuple

* fix(transcripts): align migration contract gates

* fix(transcripts): verify case-aliased export ownership

* fix(transcripts): allowlist doctor verifier sqlite query

* fix(transcripts): preflight partial artifact recovery

* fix(transcripts): stabilize exports and legacy path checks

* fix(transcripts): resolve case-renamed doctor ownership

* fix(transcripts): harden canonical export recovery

* fix(transcripts): preserve pending import boundaries

* refactor(transcripts): split migration insert transaction

* perf(transcripts): query selected summary existence
This commit is contained in:
Peter Steinberger
2026-07-23 07:18:55 -04:00
committed by GitHub
parent d124fb235f
commit a2be4efb63
32 changed files with 5179 additions and 833 deletions

View File

@@ -1,9 +1,15 @@
// Register transcripts tests cover transcript command registration and file handling.
// Transcripts CLI tests cover SQLite reads and explicit artifact materialization.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js";
import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-state-db.generated.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../../state/openclaw-state-db.js";
import { manualTranscriptSourceProvider } from "../../transcripts/manual-source.js";
import type { TranscriptSessionDescriptor } from "../../transcripts/provider-types.js";
import { TranscriptsStore } from "../../transcripts/store.js";
@@ -16,32 +22,31 @@ async function makeStateDir(): Promise<string> {
return await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-transcripts-cli-"));
}
function storeFor(stateDir: string): TranscriptsStore {
return new TranscriptsStore(path.join(stateDir, "transcripts"), {
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
});
}
async function writeSession(
stateDir: string,
sessionId: string,
date = "2026-05-22",
): Promise<string> {
const sessionDir = path.join(stateDir, "transcripts", date, sessionId);
await fs.mkdir(sessionDir, { recursive: true });
await fs.writeFile(
path.join(sessionDir, "metadata.json"),
`${JSON.stringify(
{
sessionId,
title: "Design review",
source: { providerId: "manual-transcript" },
startedAt: `${date}T10:00:00.000Z`,
stoppedAt: `${date}T10:05:00.000Z`,
},
null,
2,
)}\n`,
);
await fs.writeFile(
path.join(sessionDir, "summary.md"),
"# Design review\n\n## Action Items\n- Sam: Ship CLI\n",
);
return sessionDir;
const session: TranscriptSessionDescriptor = {
sessionId,
title: "Design review",
source: { providerId: "manual-transcript" },
startedAt: `${date}T10:00:00.000Z`,
stoppedAt: `${date}T10:05:00.000Z`,
};
const store = storeFor(stateDir);
const utterance = { text: "Action item: Ship CLI", speaker: { label: "Sam" } };
const utterances = [utterance];
await store.writeSession(session);
await store.appendUtteranceForSession(session, utterance);
await store.writeSummary(summarizeTranscripts({ session, utterances }), session);
return store.sessionDir(session);
}
async function runTranscriptsCli(args: string[]): Promise<string> {
@@ -72,6 +77,7 @@ describe("transcripts CLI", () => {
});
afterEach(() => {
closeOpenClawStateDatabaseForTest();
if (originalStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
@@ -86,7 +92,7 @@ describe("transcripts CLI", () => {
expect(program.commands.map((command) => command.name())).toContain("transcripts");
});
it("lists stored transcript sessions", async () => {
it("lists stored transcript sessions from SQLite", async () => {
const sessionDir = await writeSession(stateDir, "design-review");
const output = await runTranscriptsCli(["list"]);
@@ -96,29 +102,67 @@ describe("transcripts CLI", () => {
expect(output).toContain(path.join(sessionDir, "summary.md"));
});
it("prints summary markdown for a session", async () => {
await writeSession(stateDir, "design-review");
it("prints summary markdown and keeps its export current", async () => {
const sessionDir = await writeSession(stateDir, "design-review");
await fs.rm(sessionDir, { recursive: true, force: true });
const output = await runTranscriptsCli(["show", "design-review"]);
expect(output).toContain("# Design review");
expect(output).toContain("Ship CLI");
expect(output.endsWith("\n")).toBe(true);
const jsonOutput = JSON.parse(await runTranscriptsCli(["show", "design-review", "--json"])) as {
summary: string;
};
expect(jsonOutput.summary.endsWith("\n")).toBe(true);
await expect(fs.readFile(path.join(sessionDir, "summary.md"), "utf8")).resolves.toContain(
"Ship CLI",
);
});
it("sanitizes summaries created before the upgrade at the show boundary", async () => {
const sessionDir = await writeSession(stateDir, "legacy-summary");
await fs.writeFile(
path.join(sessionDir, "summary.md"),
"# Legacy\n\n- first\tcolumn\n- \u001b[2J\u001b[31mADMIN APPROVED\u001b[0m\n",
it("keeps JSON inspection available before a summary exists", async () => {
await storeFor(stateDir).writeSession({
sessionId: "active-session",
source: { providerId: "manual-transcript" },
startedAt: "2026-05-22T10:00:00.000Z",
});
const jsonOutput = await runTranscriptsCli(["show", "active-session", "--json"]);
expect(JSON.parse(jsonOutput)).toMatchObject({
session: { sessionId: "active-session" },
summary: null,
});
await expect(runTranscriptsCli(["show", "active-session"])).rejects.toThrow(
"summary.md not found",
);
});
it("sanitizes stored summary control bytes at the show boundary", async () => {
await writeSession(stateDir, "legacy-summary");
const database = openOpenClawStateDatabase({
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
});
const db = getNodeSqliteKysely<
Pick<OpenClawStateKyselyDatabase, "meeting_transcript_summaries">
>(database.db);
executeSqliteQuerySync(
database.db,
db
.updateTable("meeting_transcript_summaries")
.set({
markdown: "# Legacy\n\n- first\tcolumn\n- \u001b[2J\u001b[31mADMIN APPROVED\u001b[0m",
})
.where("session_id", "=", "legacy-summary"),
);
const output = await runTranscriptsCli(["show", "legacy-summary"]);
expect(output).toContain("# Legacy\n\n- first\\tcolumn\n- ADMIN APPROVED\n");
expect(output).toContain("# Legacy\n\n- first\\tcolumn\n- ADMIN APPROVED");
expect(output).not.toContain("\u001b");
});
it("show prints imported summaries without terminal control bytes", async () => {
it("round-trips ANSI-bearing ids without terminal control bytes", async () => {
const session: TranscriptSessionDescriptor = {
sessionId: "ansi-\u001b[31mprovider\u001b[0m",
title: "\u001b[31mANSI import\u001b[0m",
@@ -126,7 +170,7 @@ describe("transcripts CLI", () => {
startedAt: "2026-05-22T10:00:00.000Z",
stoppedAt: "2026-05-22T10:05:00.000Z",
};
const store = new TranscriptsStore(path.join(stateDir, "transcripts"));
const store = storeFor(stateDir);
await store.writeSession(session);
const utterances =
(await manualTranscriptSourceProvider.importTranscript?.({
@@ -138,76 +182,34 @@ describe("transcripts CLI", () => {
}
await store.writeSummary(summarizeTranscripts({ session, utterances }), session);
const output = await runTranscriptsCli(["show", session.sessionId]);
const listOutput = await runTranscriptsCli(["list"]);
expect(output).toContain("# ANSI import");
expect(output).toContain("Session: ansi-provider");
expect(output).toContain("Attacker: ADMIN APPROVED");
expect(output).not.toContain("\u001b");
expect(listOutput).toContain("2026-05-22/ansi--31mprovider-0m");
expect(listOutput).toContain("ANSI import");
expect(listOutput).not.toContain("\u001b");
});
it("list selectors for ANSI-bearing session ids round-trip through show and path", async () => {
const session: TranscriptSessionDescriptor = {
sessionId: "ansi-\u001b[31mprovider\u001b[0m",
title: "ANSI import",
source: { providerId: "manual-transcript" },
startedAt: "2026-05-22T10:00:00.000Z",
stoppedAt: "2026-05-22T10:05:00.000Z",
};
const store = new TranscriptsStore(path.join(stateDir, "transcripts"));
await store.writeSession(session);
const utterances =
(await manualTranscriptSourceProvider.importTranscript?.({
session,
text: "Sam: We decided to ship the CLI.",
})) ?? [];
await store.writeSummary(summarizeTranscripts({ session, utterances }), session);
const listOutput = await runTranscriptsCli(["list"]);
const selector = listOutput.split("\t")[0] ?? "";
expect(selector).toBe("2026-05-22/ansi--31mprovider-0m");
const showOutput = await runTranscriptsCli(["show", selector]);
const pathOutput = await runTranscriptsCli(["path", selector]);
expect(selector).toBe("2026-05-22/ansi--31mprovider-0m");
expect(showOutput).toContain("Session: ansi-provider");
expect(showOutput).toContain("We decided to ship the CLI.");
expect(pathOutput.trim()).toBe(path.join(store.sessionDir(session), "summary.md"));
expect(showOutput).toContain("Attacker: ADMIN APPROVED");
expect(`${listOutput}${showOutput}`).not.toContain("\u001b");
});
it("list --json escapes C1 control characters while JSON.parse round-trips raw values", async () => {
it("escapes C1 control characters in list JSON", async () => {
const title = "CSI \u009b31m injected \u007f\u0085 title";
const sessionDir = path.join(stateDir, "transcripts", "2026-05-22", "c1-title");
await fs.mkdir(sessionDir, { recursive: true });
await fs.writeFile(
path.join(sessionDir, "metadata.json"),
JSON.stringify({
sessionId: "c1-title",
title,
source: { providerId: "manual-transcript" },
startedAt: "2026-05-22T10:00:00.000Z",
stoppedAt: "2026-05-22T10:05:00.000Z",
}),
);
await storeFor(stateDir).writeSession({
sessionId: "c1-title",
title,
source: { providerId: "manual-transcript" },
startedAt: "2026-05-22T10:00:00.000Z",
});
const output = await runTranscriptsCli(["list", "--json"]);
const bytes = Buffer.from(output, "utf8");
expect(bytes.includes(Buffer.from([0xc2, 0x9b]))).toBe(false);
expect(bytes.includes(0x7f)).toBe(false);
expect(/[\u007f-\u009f]/.test(output)).toBe(false);
expect(output).toContain("\\u009b");
const parsed = JSON.parse(output) as Array<{ sessionId: string; title: string }>;
expect(parsed).toHaveLength(1);
expect(parsed[0]?.sessionId).toBe("c1-title");
expect(parsed[0]?.title).toBe(title);
expect(parsed).toEqual([expect.objectContaining({ sessionId: "c1-title", title })]);
});
it("ignores unrelated corrupt metadata while reading a valid session", async () => {
it("ignores unrelated corrupt export files", async () => {
await writeSession(stateDir, "design-review");
const corruptDir = path.join(stateDir, "transcripts", "corrupt");
await fs.mkdir(corruptDir, { recursive: true });
@@ -221,7 +223,7 @@ describe("transcripts CLI", () => {
expect(showOutput).toContain("# Design review");
});
it("requires date-qualified selectors for repeated human session ids", async () => {
it("requires date-qualified selectors for repeated ids", async () => {
const olderSessionDir = await writeSession(stateDir, "standup", "2026-05-21");
await writeSession(stateDir, "standup", "2026-05-22");
@@ -233,11 +235,22 @@ describe("transcripts CLI", () => {
expect(output.trim()).toBe(path.join(olderSessionDir, "summary.md"));
});
it("prints the summary path by default", async () => {
it("materializes metadata, transcript, and directory exports from SQLite", async () => {
const sessionDir = await writeSession(stateDir, "design-review");
await fs.rm(sessionDir, { recursive: true, force: true });
const output = await runTranscriptsCli(["path", "design-review"]);
const metadataOutput = await runTranscriptsCli(["path", "design-review", "--metadata"]);
const transcriptOutput = await runTranscriptsCli(["path", "design-review", "--transcript"]);
const dirOutput = await runTranscriptsCli(["path", "design-review", "--dir"]);
expect(output.trim()).toBe(path.join(sessionDir, "summary.md"));
expect(metadataOutput.trim()).toBe(path.join(sessionDir, "metadata.json"));
expect(transcriptOutput.trim()).toBe(path.join(sessionDir, "transcript.jsonl"));
expect(dirOutput.trim()).toBe(sessionDir);
await expect(fs.readFile(path.join(sessionDir, "metadata.json"), "utf8")).resolves.toContain(
'"sessionId": "design-review"',
);
await expect(fs.readFile(path.join(sessionDir, "transcript.jsonl"), "utf8")).resolves.toContain(
'"text":"Action item: Ship CLI"',
);
});
});

View File

@@ -1,12 +1,13 @@
// `openclaw transcripts`: local state inspector for stored transcript metadata and summaries.
import type { Dirent } from "node:fs";
import fs from "node:fs/promises";
// `openclaw transcripts`: SQLite-backed transcript inspector and artifact exporter.
import path from "node:path";
import type { Command } from "commander";
import { sanitizeTerminalText } from "../../../packages/terminal-core/src/safe-text.js";
import { resolveStateDir } from "../../config/paths.js";
import { formatErrorMessage } from "../../infra/errors.js";
import type { TranscriptSessionDescriptor } from "../../transcripts/provider-types.js";
import {
TranscriptsStore,
type TranscriptArtifactKind,
type TranscriptsSessionEntry,
} from "../../transcripts/store.js";
type TranscriptsCliOptions = {
json?: boolean;
@@ -18,54 +19,11 @@ type TranscriptsPathOptions = TranscriptsCliOptions & {
transcript?: boolean;
};
type StoredTranscriptsSession = {
session: TranscriptSessionDescriptor;
sessionDir: string;
date: string;
summaryPath: string;
hasSummary: boolean;
};
const TRANSCRIPTS_STATE_SUBDIR = "transcripts";
function safeSegment(value: string): string {
return value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "session";
}
function stateRootDir(): string {
return path.join(resolveStateDir(), TRANSCRIPTS_STATE_SUBDIR);
}
function dateFromSessionId(sessionId: string): string | undefined {
return sessionId
.match(/^transcript-(\d{4})-(\d{2})-(\d{2})T/)
?.slice(1, 4)
.join("-");
}
function sessionDir(date: string, sessionId: string): string {
return path.join(stateRootDir(), date, safeSegment(sessionId));
}
// Selectors are date-qualified when duplicate session ids can exist across transcript days.
function readDateFromSessionDir(sessionDirValue: string): string {
const candidate = path.basename(path.dirname(sessionDirValue));
if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) {
throw new Error(`invalid transcripts date directory: ${candidate}`);
}
return candidate;
}
function formatSelector(entry: StoredTranscriptsSession): string {
return `${entry.date}/${safeSegment(entry.session.sessionId)}`;
}
function parseQualifiedSelector(selector: string): { date: string; sessionId: string } | null {
const match = selector.match(/^(\d{4}-\d{2}-\d{2})\/(.+)$/);
if (!match?.[1] || !match[2]) {
return null;
}
return { date: match[1], sessionId: match[2] };
function createStore(): TranscriptsStore {
const stateDir = resolveStateDir();
return new TranscriptsStore(path.join(stateDir, "transcripts"), {
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
});
}
function writeLine(value: string): void {
@@ -81,163 +39,33 @@ function writeJson(value: unknown): void {
);
}
function isNodeError(err: unknown, code: string): boolean {
return Boolean(err && typeof err === "object" && "code" in err && err.code === code);
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch (err) {
if (isNodeError(err, "ENOENT")) {
return false;
}
throw err;
}
}
async function readJsonFile<T>(filePath: string): Promise<T> {
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
}
async function readStoredSession(
sessionDirLocal: string,
options: { ignoreInvalid?: boolean } = {},
): Promise<StoredTranscriptsSession | null> {
const metadataPath = path.join(sessionDirLocal, "metadata.json");
try {
const session = await readJsonFile<TranscriptSessionDescriptor>(metadataPath);
const summaryPath = path.join(sessionDirLocal, "summary.md");
return {
session,
sessionDir: sessionDirLocal,
date: readDateFromSessionDir(sessionDirLocal),
summaryPath,
hasSummary: await pathExists(summaryPath),
};
} catch (err) {
if (isNodeError(err, "ENOENT")) {
return null;
}
if (options.ignoreInvalid) {
return null;
}
throw new Error(`invalid transcripts metadata at ${metadataPath}: ${formatErrorMessage(err)}`, {
cause: err,
});
}
}
async function listStoredSessionDirs(): Promise<string[]> {
let entries: Dirent[];
try {
entries = await fs.readdir(stateRootDir(), { withFileTypes: true });
} catch (err) {
if (isNodeError(err, "ENOENT")) {
return [];
}
throw err;
}
const dirs: string[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const firstLevelDir = path.join(stateRootDir(), entry.name);
if (!/^\d{4}-\d{2}-\d{2}$/.test(entry.name)) {
continue;
}
const nestedEntries = await fs.readdir(firstLevelDir, { withFileTypes: true });
dirs.push(
...nestedEntries
.filter((nestedEntry) => nestedEntry.isDirectory())
.map((nestedEntry) => path.join(firstLevelDir, nestedEntry.name)),
);
}
return dirs;
}
function assertRequestedSession(
entry: StoredTranscriptsSession,
sessionId: string,
): StoredTranscriptsSession {
if (entry.session.sessionId !== sessionId && safeSegment(entry.session.sessionId) !== sessionId) {
throw new Error(
`transcripts metadata mismatch for ${sessionId}: found ${entry.session.sessionId}`,
);
}
return entry;
}
async function requireStoredSession(selector: string): Promise<StoredTranscriptsSession> {
const qualified = parseQualifiedSelector(selector);
if (qualified) {
const session = await readStoredSession(sessionDir(qualified.date, qualified.sessionId));
if (!session) {
throw new Error(`transcripts session not found: ${selector}`);
}
return assertRequestedSession(session, qualified.sessionId);
}
const idDate = dateFromSessionId(selector);
const session = idDate ? await readStoredSession(sessionDir(idDate, selector)) : null;
if (session) {
return assertRequestedSession(session, selector);
}
const sessions = await listStoredSessions();
const matches = sessions.filter(
(entry) =>
entry.session.sessionId === selector || safeSegment(entry.session.sessionId) === selector,
);
if (matches.length === 1 && matches[0]) {
return assertRequestedSession(matches[0], selector);
}
if (matches.length > 1) {
throw new Error(
`multiple transcripts sessions match ${selector}; use one of: ${matches
.map(formatSelector)
.join(", ")}`,
);
}
throw new Error(`transcripts session not found: ${selector}`);
}
async function listStoredSessions(): Promise<StoredTranscriptsSession[]> {
const dirs = await listStoredSessionDirs();
const sessions = await Promise.all(
dirs.map((dir) =>
readStoredSession(dir, {
ignoreInvalid: true,
}),
),
);
return sessions
.filter((session): session is StoredTranscriptsSession => session !== null)
.toSorted((left, right) =>
(right.session.startedAt ?? "").localeCompare(left.session.startedAt ?? ""),
);
}
function formatSessionLine(entry: StoredTranscriptsSession): string {
const title = sanitizeTerminalText(entry.session.title?.trim() || "Transcripts");
const started = sanitizeTerminalText(entry.session.startedAt || "unknown");
const summary = sanitizeTerminalText(entry.hasSummary ? entry.summaryPath : "no summary.md");
return `${formatSelector(entry)}\t${started}\t${title}\t${summary}`;
}
function sanitizeMarkdownForTerminal(markdown: string): string {
return markdown.split("\n").map(sanitizeTerminalText).join("\n");
}
function formatSessionLine(entry: TranscriptsSessionEntry): string {
const title = sanitizeTerminalText(entry.session.title?.trim() || "Transcripts");
const started = sanitizeTerminalText(entry.session.startedAt || "unknown");
const summary = sanitizeTerminalText(entry.hasSummary ? entry.summaryPath : "no summary.md");
return `${entry.selector}\t${started}\t${title}\t${summary}`;
}
async function requireStoredSession(selector: string): Promise<TranscriptsSessionEntry> {
const session = await createStore().readSessionEntry(selector);
if (!session) {
throw new Error(`transcripts session not found: ${selector}`);
}
return session;
}
async function listCommand(options: TranscriptsCliOptions): Promise<void> {
const sessions = await listStoredSessions();
const sessions = await createStore().listSessionEntries();
if (options.json) {
writeJson(
sessions.map((entry) => ({
sessionId: entry.session.sessionId,
selector: formatSelector(entry),
date: entry.date,
selector: entry.selector,
date: entry.selector.slice(0, 10),
title: entry.session.title,
startedAt: entry.session.startedAt,
stoppedAt: entry.session.stoppedAt,
@@ -258,47 +86,76 @@ async function listCommand(options: TranscriptsCliOptions): Promise<void> {
}
}
async function showCommand(sessionId: string, options: TranscriptsCliOptions): Promise<void> {
const session = await requireStoredSession(sessionId);
async function showCommand(sessionSelector: string, options: TranscriptsCliOptions): Promise<void> {
const store = createStore();
const entry = await store.readSessionEntry(sessionSelector);
if (!entry) {
throw new Error(`transcripts session not found: ${sessionSelector}`);
}
const storedSummary = await store.readSummary(entry.session);
const materializedMarkdown =
storedSummary.markdown === undefined
? undefined
: storedSummary.markdown.endsWith("\n")
? storedSummary.markdown
: `${storedSummary.markdown}\n`;
// `show` is an explicit export boundary: keep the shipped summary path current.
await store.materializeSessionArtifacts(entry.session, "summary");
if (options.json) {
const summary = session.hasSummary ? await fs.readFile(session.summaryPath, "utf8") : null;
writeJson({
session: session.session,
selector: formatSelector(session),
path: session.sessionDir,
summaryPath: session.summaryPath,
summary,
session: entry.session,
selector: entry.selector,
path: entry.sessionDir,
summaryPath: entry.summaryPath,
summary: materializedMarkdown ?? null,
});
return;
}
if (!session.hasSummary) {
throw new Error(`summary.md not found for transcripts session: ${sessionId}`);
if (materializedMarkdown === undefined) {
throw new Error(`summary.md not found for transcripts session: ${sessionSelector}`);
}
process.stdout.write(sanitizeMarkdownForTerminal(await fs.readFile(session.summaryPath, "utf8")));
process.stdout.write(sanitizeMarkdownForTerminal(materializedMarkdown));
}
function selectedArtifactKind(options: TranscriptsPathOptions): TranscriptArtifactKind {
if (options.dir) {
return "all";
}
if (options.metadata) {
return "metadata";
}
if (options.transcript) {
return "transcript";
}
return "summary";
}
async function pathCommand(selector: string, options: TranscriptsPathOptions): Promise<void> {
const session = await requireStoredSession(selector);
const store = createStore();
const entry = await requireStoredSession(selector);
const kind = selectedArtifactKind(options);
const artifacts = await store.materializeSessionArtifacts(entry.session, kind);
const selectedPath = options.dir
? session.sessionDir
? artifacts.sessionDir
: options.metadata
? path.join(session.sessionDir, "metadata.json")
? artifacts.metadataPath
: options.transcript
? path.join(session.sessionDir, "transcript.jsonl")
: session.summaryPath;
? artifacts.transcriptPath
: artifacts.summaryPath;
const exists = kind !== "summary" || artifacts.hasSummary;
if (options.json) {
writeJson({
sessionId: session.session.sessionId,
selector: formatSelector(session),
sessionId: entry.session.sessionId,
selector: entry.selector,
path: selectedPath,
exists: await pathExists(selectedPath),
exists,
});
return;
}
writeLine(selectedPath);
}
/** Register transcript list/show/path inspection commands. */
/** Register transcript list/show/path inspection and export commands. */
export function registerTranscriptsCli(program: Command): void {
const transcripts = program.command("transcripts").description("Inspect stored transcripts");
@@ -312,7 +169,7 @@ export function registerTranscriptsCli(program: Command): void {
transcripts
.command("show")
.description("Print a transcript summary markdown file")
.description("Print and materialize a transcript summary")
.argument("<session>", "Transcripts session id or YYYY-MM-DD/session selector")
.option("--json", "Print JSON")
.action(async (sessionId: string, options: TranscriptsCliOptions) => {
@@ -321,11 +178,11 @@ export function registerTranscriptsCli(program: Command): void {
transcripts
.command("path")
.description("Print a stored transcripts artifact path")
.description("Materialize and print a stored transcripts artifact path")
.argument("<session>", "Transcripts session id or YYYY-MM-DD/session selector")
.option("--dir", "Print the session directory")
.option("--metadata", "Print metadata.json")
.option("--transcript", "Print transcript.jsonl")
.option("--dir", "Materialize all artifacts and print the session directory")
.option("--metadata", "Materialize and print metadata.json")
.option("--transcript", "Materialize and print transcript.jsonl")
.option("--json", "Print JSON")
.action(async (sessionId: string, options: TranscriptsPathOptions) => {
await pathCommand(sessionId, options);