Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 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
7b48e89
fix(runtime): keep the subagent export in breadth-first order
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
- Stop pipe-mode shell commands when their Runtime Host dies, instead of leaving them running without an owner (#5986).
- Keep native Anthropic prompt caching effective across steps and turns by placing the cache breakpoint on replayed history (#6041).
- Update shipped dependencies to clear published advisories in brace-expansion, fast-uri and axios (#5906).
- Keep the Desktop clipboard export's Markdown shape — bulleted tool rows, one shared Tool calls block per run, and the italic exported-at line — now that it and the CLI transcript export render through the same conversation renderer, which prints redacted secrets as `[redacted]` (#6036).

### Changed

Expand Down
50 changes: 50 additions & 0 deletions apps/desktop/src/main/__tests__/conversation-markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,54 @@ describe('renderConversationMarkdown', () => {
assert.ok(!md.includes('Secret skill body'), 'export must not include skill body');
assert.match(renderConversationMarkdown('skill session', messages, 'en'), /## You/);
});

it('keeps the clipboard shape: italic exported line, bulleted rows, one tool-call block, [redacted] secrets', () => {
// The clipboard export predates the shared core renderer; pin its shape so
// the shared path cannot silently change what users paste (#6036 review).
const messages: StoredMessage[] = [
{ type: 'user', id: 'u1', turnId: 't1', ts: 1, text: 'run the tools' },
{
type: 'tool_call',
id: 'c1',
turnId: 't1',
ts: 2,
toolName: 'read_file',
intent: 'read the sk-intent-secret config',
args: {},
},
{
type: 'tool_call',
id: 'c2',
turnId: 't1',
ts: 3,
toolName: 'list_dir',
intent: 'list the entries',
args: {},
},
{
type: 'assistant',
id: 'a1',
turnId: 't1',
ts: 4,
text: 'the sk-answer-secret file is ready',
modelId: 'fake',
},
];
const md = renderConversationMarkdown('shape session', messages, 'zh-CN');
// The exported-at line stays italic, as the clipboard export always had it.
assert.match(md, /# shape session\n\n\*由 Maka 于 .+ 导出。\*\n/);
// A run of tool calls shares one section; each call must not open its own.
assert.equal((md.match(/### 工具调用/g) ?? []).length, 1, md);
// Rows keep their `- ` list marker.
assert.ok(md.includes('- `read_file` — read the [redacted] config'), md);
assert.ok(md.includes('- `list_dir` — list the entries'), md);
// Model-produced strings pass the shared redactor, and the placeholder is
// the `[redacted]` the CLI export prints (#5958 alignment) — never the raw
// secret.
assert.ok(!md.includes('sk-intent-secret'));
assert.ok(!md.includes('sk-answer-secret'));
assert.ok((md.match(/\[redacted\]/g) ?? []).length >= 2, md);
assert.ok(md.includes('## Maka'));
assert.ok(md.includes('the [redacted] file is ready'), md);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -17,95 +17,42 @@
* under the License.
*/

import { renderConversationMarkdown as renderConversationMarkdownCore } from '@maka/core/conversation-markdown';
import type { StoredMessage } from '@maka/core/session';
import type { UiLocale } from '@maka/core/ui-locale';
import { userFacingText } from '@maka/core/session';
import { redactSecrets } from '@maka/ui';
import { getShellRemainingCopy } from '../../../locales/shell-remaining-copy.js';

/**
* Serialize a conversation to a Markdown document suitable for pasting into
* Notion / Obsidian / GitHub. One section per turn: `## 你` header for the
* user message, optional `### 工具调用` block enumerating tool calls + their
* intent, `## Maka` for the assistant answer.
*
* Per @kenji's PR86 review, deliberate exclusions:
* - **thinking block** is never included — that's model working notes, not
* the answer. If we ever add an "include thinking" toggle, it must be a
* separate opt-in.
* - **token_usage / permission_decision / tool_result** rows dropped —
* operational records, not narrative.
* - **tool intents** run through `redactSecrets` defensively in case a
* model-authored intent happens to echo a path / token.
* - **assistant text** runs through `redactSecrets` defensively — backend
* already redacts at write-time, but a fresh AI-SDK error path that
* somehow lands a raw token in `text` shouldn't survive into a clipboard
* export that the user is going to paste somewhere public.
* - **user text** left untouched (the user typed it, they own it).
* Serialize the open conversation to a Markdown document suitable for pasting
* into Notion / Obsidian / GitHub. The rendering itself lives in core
* (`renderConversationMarkdown`) so the Desktop clipboard export and the CLI
* transcript export cannot drift apart again (#5958, #6036 review) — this
* adapter only carries the locale copy and the light clipboard shape: tool
* results and thinking stay out, matching the @kenji precedent, and the
* redactor is the same one the CLI export uses.
*/
export function renderConversationMarkdown(sessionName: string, messages: readonly StoredMessage[], locale: UiLocale): string {
export function renderConversationMarkdown(
sessionName: string,
messages: readonly StoredMessage[],
locale: UiLocale,
): string {
const copy = getShellRemainingCopy(locale).conversationExport;
const lines: string[] = [];
lines.push(`# ${sessionName}`);
lines.push('');
lines.push(`*${copy.exported(new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date()))}*`);
lines.push('');

// Group by turnId in encounter order so we preserve narrative flow.
const turnOrder: string[] = [];
const byTurn = new Map<string, StoredMessage[]>();
for (const m of messages) {
const tid = (m as { turnId?: string }).turnId ?? '__loose';
if (!byTurn.has(tid)) {
byTurn.set(tid, []);
turnOrder.push(tid);
}
byTurn.get(tid)!.push(m);
}

for (const tid of turnOrder) {
const turnMessages = byTurn.get(tid) ?? [];
const user = turnMessages.find((m) => m.type === 'user');
// A turn holds one assistant message per model step; join their text in step
// order so the export carries the whole answer, not just the first step.
const assistantText = turnMessages
.flatMap((m) => (m.type === 'assistant' && m.text.length > 0 ? [m.text] : []))
.join('\n\n');
const toolCalls = turnMessages.filter((m) => m.type === 'tool_call');

if (user && user.type === 'user') {
lines.push('---');
lines.push('');
lines.push(`## ${copy.you}`);
lines.push('');
// Prefer displayText when the model text is a composed envelope (skill
// invocation) so exports match what the user typed, not the injected body.
lines.push(userFacingText(user));
lines.push('');
}

if (toolCalls.length > 0) {
lines.push(`### ${copy.toolCalls}`);
lines.push('');
for (const call of toolCalls) {
const c = call as { toolName: string; intent?: string };
const intent = c.intent ? redactSecrets(c.intent) : undefined;
const intentSuffix = intent ? `${copy.intentSeparator}${intent}` : '';
lines.push(`- \`${c.toolName}\`${intentSuffix}`);
}
lines.push('');
}

if (assistantText.length > 0) {
lines.push('## Maka');
lines.push('');
// Defensive: backend redacts at write-time, but the export landing
// in the user's clipboard is a high-risk surface — paste destinations
// are external. Second-layer redaction is cheap insurance.
lines.push(redactSecrets(assistantText));
lines.push('');
}
}

return lines.join('\n').trim() + '\n';
return renderConversationMarkdownCore(sessionName, messages, {
copy: {
you: copy.you,
assistant: 'Maka',
toolCalls: copy.toolCalls,
thinking: 'Thinking',
intentSeparator: copy.intentSeparator,
},
includeToolResults: false,
// The clipboard export has always set its exported-at line in italics;
// keep that shape on the shared renderer (#6036 review).
exportedAtLine: (isoTimestamp) =>
`*${copy.exported(
new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(
new Date(isoTimestamp),
),
)}*`,
});
}
Loading
Loading