mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-04 10:41:33 +00:00
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:
committed by
GitHub
parent
d124fb235f
commit
a2be4efb63
@@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user