Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a8affc4
feat(cli): export a full Session transcript as Markdown from persistence
ggbdpq Oct 9, 2026
842f3da
fix(cli): read the ledger transcript, redact every branch, publish at…
ggbdpq Oct 10, 2026
fb90098
fix(runtime): redact file-result paths and file-write destinations
ggbdpq Oct 10, 2026
a306c1a
fix(runtime): open transcript exports read-only on the operational da…
ggbdpq Oct 13, 2026
1fef7e1
feat(core): one shared stored-order conversation Markdown renderer
ggbdpq Oct 13, 2026
21b0ed5
refactor(runtime): render transcript exports through the shared core …
ggbdpq Oct 13, 2026
0f89eae
feat(runtime): stream transcript exports to a file and include subage…
ggbdpq Oct 13, 2026
28c6587
feat(runtime,cli): stream the transcript export to file, add subagent…
ggbdpq Oct 13, 2026
0ee0c2b
refactor(desktop): render the clipboard export through the shared cor…
ggbdpq Oct 13, 2026
1ab2ca4
Merge remote-tracking branch 'upstream/main' into fix/5958-transcript…
ggbdpq Oct 13, 2026
1457334
fix(runtime,storage): stored-position tool results, drained writes, r…
ggbdpq Oct 13, 2026
ebd03da
fix(runtime): receive export storage openers from composition
ggbdpq Oct 13, 2026
ca8f80f
fix(core,desktop): keep the clipboard tool-call shape on the shared r…
ggbdpq Oct 18, 2026
1e81706
fix(cli): map transcript export failures to a clean exit
ggbdpq Oct 18, 2026
1ac5fd3
fix(runtime): harden the file export and carry every subagent once
ggbdpq Oct 18, 2026
e43a41e
Merge remote-tracking branch 'upstream/main' into fix/5958-transcript…
ggbdpq Oct 18, 2026
d927b83
fix(core): close the tool run when a steering message starts a new se…
ggbdpq Oct 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions packages/cli/src/__tests__/session-export-markdown-command.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import assert from 'node:assert/strict';
import { lstat, mkdtemp, mkdir, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, test } from 'node:test';
import { createSessionStore } from '@maka/storage/session-store';
import type { StoredMessage } from '@maka/core/session';
import { runMakaSessionExportMarkdownCli } from '../session-export-markdown-command.js';

const CLEANUPS: (() => Promise<void>)[] = [];

afterEach(async () => {
while (CLEANUPS.length > 0) {
await CLEANUPS.pop()!();
}
});

async function makeWorkspace(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'session-export-markdown-cli-'));
const workspaceRoot = join(root, 'workspace');
await mkdir(workspaceRoot, { recursive: true });
CLEANUPS.push(() => rm(root, { recursive: true, force: true }));
return workspaceRoot;
}

async function seedSession(workspaceRoot: string): Promise<string> {
const store = createSessionStore(workspaceRoot);
try {
const header = await store.create({
cwd: workspaceRoot,
llmConnectionSlug: 'test-connection',
model: 'test-model',
permissionMode: 'ask',
name: 'CLI export',
});
const messages: StoredMessage[] = [
{ type: 'user', id: 'u1', turnId: 'turn-1', ts: 1, text: 'hello' },
{
type: 'assistant',
id: 'a1',
turnId: 'turn-1',
ts: 2,
text: 'Hi there.',
modelId: 'test-model',
},
];
await store.appendMessages(header.id, messages);
return header.id;
} finally {
await store.close?.();
}
}

describe('maka session-export-markdown', () => {
test('writes the transcript and exits 0', async () => {
const workspaceRoot = await makeWorkspace();
const sessionId = await seedSession(workspaceRoot);
const destination = join(workspaceRoot, 'transcript.md');

const exit = await runMakaSessionExportMarkdownCli([
'--workspace-root',
workspaceRoot,
'--session',
sessionId,
'--out',
destination,
]);

assert.equal(exit, 0);
const markdown = await readFile(destination, 'utf8');
assert.ok(markdown.startsWith('# CLI export\n'));
assert.ok(markdown.includes('## You'));
assert.ok(markdown.includes('hello'));
assert.ok(markdown.includes('## Maka'));
assert.ok(markdown.includes('Hi there.'));
});

test('never overwrites an existing file', async () => {
const workspaceRoot = await makeWorkspace();
const sessionId = await seedSession(workspaceRoot);
const destination = join(workspaceRoot, 'keep.md');
await writeFile(destination, 'do not touch\n', 'utf8');

const exit = await runMakaSessionExportMarkdownCli([
'--workspace-root',
workspaceRoot,
'--session',
sessionId,
'--out',
destination,
]);

assert.equal(exit, 5);
assert.equal(await readFile(destination, 'utf8'), 'do not touch\n');
});

test('refuses a dangling symlink destination without following it', async () => {
const workspaceRoot = await makeWorkspace();
const sessionId = await seedSession(workspaceRoot);
const target = join(workspaceRoot, 'target.md');
const destination = join(workspaceRoot, 'link.md');
await symlink(target, destination);

const exit = await runMakaSessionExportMarkdownCli([
'--workspace-root',
workspaceRoot,
'--session',
sessionId,
'--out',
destination,
]);

assert.equal(exit, 5);
assert.equal(
await readFile(target, 'utf8').then(
() => 'written',
() => 'absent',
),
'absent',
);
assert.equal((await lstat(destination)).isSymbolicLink(), true);
});

test('reports a missing session with exit code 2', async () => {
const workspaceRoot = await makeWorkspace();
const destination = join(workspaceRoot, 'absent.md');

const exit = await runMakaSessionExportMarkdownCli([
'--workspace-root',
workspaceRoot,
'--session',
'does-not-exist',
'--out',
destination,
]);

assert.equal(exit, 2);
assert.equal(
await readFile(destination, 'utf8').then(
() => 'written',
() => 'absent',
),
'absent',
);
});

test('rejects missing arguments with exit code 1', async () => {
assert.equal(await runMakaSessionExportMarkdownCli(['--session', 's1']), 1);
assert.equal(await runMakaSessionExportMarkdownCli([]), 1);
});
});
11 changes: 11 additions & 0 deletions packages/cli/src/cli-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export type MakaCliCommand =
| { kind: 'activate'; args: string[] }
| { kind: 'session-export'; args: string[] }
| { kind: 'session-import'; args: string[] }
| { kind: 'session-export-markdown'; args: string[] }
| { kind: 'eval'; args: string[] }
| { kind: 'acp' }
| RuntimeHostCliCommand
Expand Down Expand Up @@ -91,6 +92,9 @@ export function parseMakaCliArgs(
? { kind: 'help', text: sessionBundleHelpText(cliCommand, first) }
: { kind: first, args: argv.slice(1) };
}
if (first === 'session-export-markdown') {
return { kind: first, args: argv.slice(1) };
}
if (first === 'eval') return { kind: 'eval', args: argv.slice(1) };
if (first === 'update') return parseRuntimeHostInstalledUpdateCommand(argv.slice(1), cliCommand);
if (first === 'runtime-host') return parseRuntimeHostCommand(argv.slice(1), cliCommand);
Expand Down Expand Up @@ -150,6 +154,7 @@ function helpText(cliCommand: string): string {
['eval ...', 'Run one declarative multi-arm experiment'],
['session-export ...', 'Write a Session bundle to a file'],
['session-import ...', 'Read a Session bundle back into a workspace'],
['session-export-markdown ...', 'Write a full Session transcript as Markdown'],
['update ...', 'Update this npm-global CLI and its local Runtime Host'],
['runtime-host ...', 'Serve and manage a Runtime Host'],
] as const
Expand Down Expand Up @@ -228,6 +233,12 @@ export async function runMakaCli(
const { runMakaSessionImportCli } = await import('./session-import-command.js');
return runMakaSessionImportCli(command.args);
}
case 'session-export-markdown': {
const { runMakaSessionExportMarkdownCli } = await import(
'./session-export-markdown-command.js'
);
return runMakaSessionExportMarkdownCli(command.args);
}
case 'eval': {
const { configureInstalledEvalBundle } = await import('./eval-bundle-path.js');
configureInstalledEvalBundle();
Expand Down
118 changes: 118 additions & 0 deletions packages/cli/src/session-export-markdown-command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import { open, stat } from 'node:fs/promises';
import { exportSessionTranscriptMarkdown } from '@maka/runtime/session-transcript-export';

const USAGE =
'maka session-export-markdown --workspace-root <dir> --session <id> --out <file.md> [--include-thinking]';

const FAILURE_EXIT_CODES: Record<string, number> = {
session_not_found: 2,
destination_exists: 5,
workspace_not_found: 6,
};

interface ParsedArgs {
workspaceRoot: string;
sessionId: string;
destination: string;
includeThinking: boolean;
}

function parseArgs(args: string[]): ParsedArgs {
const values: Record<string, string> = {};
let includeThinking = false;
for (let index = 0; index < args.length; index += 1) {
const name = args[index];
if (name === '--include-thinking') {
includeThinking = true;
continue;
}
const value = args[index + 1];
if (!name || !value) throw new Error(USAGE);
if (name === '--workspace-root') values.workspaceRoot = value;
else if (name === '--session') values.sessionId = value;
else if (name === '--out') values.destination = value;
else throw new Error(USAGE);
index += 1;
}
if (!values.workspaceRoot || !values.sessionId || !values.destination) throw new Error(USAGE);
return {
workspaceRoot: values.workspaceRoot,
sessionId: values.sessionId,
destination: values.destination,
includeThinking,
};
}

export async function runMakaSessionExportMarkdownCli(args: string[]): Promise<number> {
let parsed: ParsedArgs;
try {
parsed = parseArgs(args);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
return 1;
}
// Aligned with the bundle exporter: an existing path is never overwritten.
// The stat is only the friendly precheck; the exclusive creation below is
// what actually refuses existing destinations — a file created after this
// check, or a dangling symlink the check reads as absent, both land on EEXIST
// instead of being truncated or followed.
if (
await stat(parsed.destination)
.then(() => true)
.catch(() => false)
) {
process.stderr.write(`${JSON.stringify({ kind: 'destination_exists' })}\n`);
return FAILURE_EXIT_CODES.destination_exists;
}
const result = await exportSessionTranscriptMarkdown({
workspaceRoot: parsed.workspaceRoot,
sessionId: parsed.sessionId,
includeThinking: parsed.includeThinking,
});
if (!result.ok) {
process.stderr.write(`${JSON.stringify(result.reason)}\n`);
return FAILURE_EXIT_CODES[result.reason.kind] ?? 1;
}
let destination: Awaited<ReturnType<typeof open>>;
try {
destination = await open(parsed.destination, 'wx');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
process.stderr.write(`${JSON.stringify({ kind: 'destination_exists' })}\n`);
return FAILURE_EXIT_CODES.destination_exists;
}
throw error;
}
try {
await destination.writeFile(result.markdown, 'utf8');
} finally {
await destination.close();
}
process.stdout.write(
`${JSON.stringify({
session: parsed.sessionId,
out: parsed.destination,
messageCount: result.messageCount,
})}\n`,
);
return 0;
}
1 change: 1 addition & 0 deletions packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
"./scheduled-task-tools": "./dist/scheduled-task-tools.js",
"./session-recap": "./dist/session-recap.js",
"./session-export": "./dist/session-export.js",
"./session-transcript-export": "./dist/session-transcript-export.js",
"./session-trace-projection": "./dist/session-trace-projection.js",
"./shell-detect": "./dist/shell-detect.js",
"./shell-run-contract": "./dist/shell-run-contract.js",
Expand Down
Loading
Loading