mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 17:21:17 +00:00
refactor: move transcripts into core
Move meeting notes into core transcripts, remove the bundled meeting-notes plugin/API, and require explicit transcripts.enabled before exposing the recording-capable tool.
This commit is contained in:
committed by
GitHub
parent
45feb37b13
commit
cac0b2db18
@@ -107,6 +107,11 @@ const coreEntrySpecs: readonly CommandGroupDescriptorSpec<
|
||||
loadModule: () => import("../mcp-cli.js"),
|
||||
exportName: "registerMcpCli",
|
||||
},
|
||||
{
|
||||
commandNames: ["transcripts"],
|
||||
loadModule: () => import("./register.transcripts.js"),
|
||||
exportName: "registerTranscriptsCli",
|
||||
},
|
||||
]),
|
||||
),
|
||||
defineImportedCommandGroupSpec(
|
||||
|
||||
@@ -71,6 +71,11 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
|
||||
hasSubcommands: true,
|
||||
parentDefaultHelp: true,
|
||||
},
|
||||
{
|
||||
name: "transcripts",
|
||||
description: "Inspect stored transcripts",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
{
|
||||
name: "agent",
|
||||
description: "Run one agent turn via the Gateway",
|
||||
|
||||
136
src/cli/program/register.transcripts.test.ts
Normal file
136
src/cli/program/register.transcripts.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
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 { registerTranscriptsCli } from "./register.transcripts.js";
|
||||
|
||||
const originalStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
|
||||
async function makeStateDir(): Promise<string> {
|
||||
return await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-transcripts-cli-"));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function runTranscriptsCli(args: string[]): Promise<string> {
|
||||
let output = "";
|
||||
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((
|
||||
chunk: string | Uint8Array,
|
||||
) => {
|
||||
output += String(chunk);
|
||||
return true;
|
||||
}) as typeof process.stdout.write);
|
||||
try {
|
||||
const program = new Command();
|
||||
program.name("openclaw");
|
||||
registerTranscriptsCli(program);
|
||||
await program.parseAsync(["transcripts", ...args], { from: "user" });
|
||||
return output;
|
||||
} finally {
|
||||
writeSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
|
||||
describe("transcripts CLI", () => {
|
||||
let stateDir = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
stateDir = await makeStateDir();
|
||||
process.env.OPENCLAW_STATE_DIR = stateDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = originalStateDir;
|
||||
}
|
||||
});
|
||||
|
||||
it("registers a kebab-case command", () => {
|
||||
const program = new Command();
|
||||
registerTranscriptsCli(program);
|
||||
|
||||
expect(program.commands.map((command) => command.name())).toContain("transcripts");
|
||||
});
|
||||
|
||||
it("lists stored transcript sessions", async () => {
|
||||
const sessionDir = await writeSession(stateDir, "design-review");
|
||||
|
||||
const output = await runTranscriptsCli(["list"]);
|
||||
|
||||
expect(output).toContain("2026-05-22/design-review");
|
||||
expect(output).toContain("Design review");
|
||||
expect(output).toContain(path.join(sessionDir, "summary.md"));
|
||||
});
|
||||
|
||||
it("prints summary markdown for a session", async () => {
|
||||
await writeSession(stateDir, "design-review");
|
||||
|
||||
const output = await runTranscriptsCli(["show", "design-review"]);
|
||||
|
||||
expect(output).toContain("# Design review");
|
||||
expect(output).toContain("Ship CLI");
|
||||
});
|
||||
|
||||
it("ignores unrelated corrupt metadata while reading a valid session", async () => {
|
||||
await writeSession(stateDir, "design-review");
|
||||
const corruptDir = path.join(stateDir, "transcripts", "corrupt");
|
||||
await fs.mkdir(corruptDir, { recursive: true });
|
||||
await fs.writeFile(path.join(corruptDir, "metadata.json"), "{nope");
|
||||
|
||||
const listOutput = await runTranscriptsCli(["list"]);
|
||||
const showOutput = await runTranscriptsCli(["show", "design-review"]);
|
||||
|
||||
expect(listOutput).toContain("design-review");
|
||||
expect(listOutput).not.toContain("corrupt");
|
||||
expect(showOutput).toContain("# Design review");
|
||||
});
|
||||
|
||||
it("requires date-qualified selectors for repeated human session ids", async () => {
|
||||
const olderSessionDir = await writeSession(stateDir, "standup", "2026-05-21");
|
||||
await writeSession(stateDir, "standup", "2026-05-22");
|
||||
|
||||
await expect(runTranscriptsCli(["path", "standup"])).rejects.toThrow(
|
||||
"multiple transcripts sessions match standup",
|
||||
);
|
||||
const output = await runTranscriptsCli(["path", "2026-05-21/standup"]);
|
||||
|
||||
expect(output.trim()).toBe(path.join(olderSessionDir, "summary.md"));
|
||||
});
|
||||
|
||||
it("prints the summary path by default", async () => {
|
||||
const sessionDir = await writeSession(stateDir, "design-review");
|
||||
|
||||
const output = await runTranscriptsCli(["path", "design-review"]);
|
||||
|
||||
expect(output.trim()).toBe(path.join(sessionDir, "summary.md"));
|
||||
});
|
||||
});
|
||||
320
src/cli/program/register.transcripts.ts
Normal file
320
src/cli/program/register.transcripts.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import type { Dirent } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Command } from "commander";
|
||||
import { resolveStateDir } from "../../config/paths.js";
|
||||
import type { TranscriptSessionDescriptor } from "../../transcripts/provider-types.js";
|
||||
|
||||
type TranscriptsCliOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
type TranscriptsPathOptions = TranscriptsCliOptions & {
|
||||
dir?: boolean;
|
||||
metadata?: boolean;
|
||||
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));
|
||||
}
|
||||
|
||||
function readDateFromSessionDir(sessionDir: string): string {
|
||||
const candidate = path.basename(path.dirname(sessionDir));
|
||||
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}/${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 writeLine(value: string): void {
|
||||
process.stdout.write(`${value}\n`);
|
||||
}
|
||||
|
||||
function writeJson(value: unknown): void {
|
||||
writeLine(JSON.stringify(value, null, 2));
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function formatErrorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
async function readStoredSession(
|
||||
sessionDir: string,
|
||||
options: { ignoreInvalid?: boolean } = {},
|
||||
): Promise<StoredTranscriptsSession | null> {
|
||||
const metadataPath = path.join(sessionDir, "metadata.json");
|
||||
try {
|
||||
const session = await readJsonFile<TranscriptSessionDescriptor>(metadataPath);
|
||||
const summaryPath = path.join(sessionDir, "summary.md");
|
||||
return {
|
||||
session,
|
||||
sessionDir,
|
||||
date: readDateFromSessionDir(sessionDir),
|
||||
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) {
|
||||
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);
|
||||
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 = entry.session.title?.trim() || "Transcripts";
|
||||
const started = entry.session.startedAt || "unknown";
|
||||
const summary = entry.hasSummary ? entry.summaryPath : "no summary.md";
|
||||
return `${formatSelector(entry)}\t${started}\t${title}\t${summary}`;
|
||||
}
|
||||
|
||||
async function listCommand(options: TranscriptsCliOptions): Promise<void> {
|
||||
const sessions = await listStoredSessions();
|
||||
if (options.json) {
|
||||
writeJson(
|
||||
sessions.map((entry) => ({
|
||||
sessionId: entry.session.sessionId,
|
||||
selector: formatSelector(entry),
|
||||
date: entry.date,
|
||||
title: entry.session.title,
|
||||
startedAt: entry.session.startedAt,
|
||||
stoppedAt: entry.session.stoppedAt,
|
||||
source: entry.session.source,
|
||||
path: entry.sessionDir,
|
||||
summaryPath: entry.summaryPath,
|
||||
hasSummary: entry.hasSummary,
|
||||
})),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (sessions.length === 0) {
|
||||
writeLine("No transcripts found.");
|
||||
return;
|
||||
}
|
||||
for (const session of sessions) {
|
||||
writeLine(formatSessionLine(session));
|
||||
}
|
||||
}
|
||||
|
||||
async function showCommand(sessionId: string, options: TranscriptsCliOptions): Promise<void> {
|
||||
const session = await requireStoredSession(sessionId);
|
||||
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,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!session.hasSummary) {
|
||||
throw new Error(`summary.md not found for transcripts session: ${sessionId}`);
|
||||
}
|
||||
process.stdout.write(await fs.readFile(session.summaryPath, "utf8"));
|
||||
}
|
||||
|
||||
async function pathCommand(selector: string, options: TranscriptsPathOptions): Promise<void> {
|
||||
const session = await requireStoredSession(selector);
|
||||
const selectedPath = options.dir
|
||||
? session.sessionDir
|
||||
: options.metadata
|
||||
? path.join(session.sessionDir, "metadata.json")
|
||||
: options.transcript
|
||||
? path.join(session.sessionDir, "transcript.jsonl")
|
||||
: session.summaryPath;
|
||||
if (options.json) {
|
||||
writeJson({
|
||||
sessionId: session.session.sessionId,
|
||||
selector: formatSelector(session),
|
||||
path: selectedPath,
|
||||
exists: await pathExists(selectedPath),
|
||||
});
|
||||
return;
|
||||
}
|
||||
writeLine(selectedPath);
|
||||
}
|
||||
|
||||
export function registerTranscriptsCli(program: Command): void {
|
||||
const transcripts = program.command("transcripts").description("Inspect stored transcripts");
|
||||
|
||||
transcripts
|
||||
.command("list")
|
||||
.description("List stored transcript sessions")
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (options: TranscriptsCliOptions) => {
|
||||
await listCommand(options);
|
||||
});
|
||||
|
||||
transcripts
|
||||
.command("show")
|
||||
.description("Print a transcript summary markdown file")
|
||||
.argument("<session>", "Transcripts session id or YYYY-MM-DD/session selector")
|
||||
.option("--json", "Print JSON")
|
||||
.action(async (sessionId: string, options: TranscriptsCliOptions) => {
|
||||
await showCommand(sessionId, options);
|
||||
});
|
||||
|
||||
transcripts
|
||||
.command("path")
|
||||
.description("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("--json", "Print JSON")
|
||||
.action(async (sessionId: string, options: TranscriptsPathOptions) => {
|
||||
await pathCommand(sessionId, options);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user