diff --git a/ROADMAP.md b/ROADMAP.md index 0467b92..75bf21e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -38,7 +38,8 @@ por trás, assinaturas existentes e lançamento conjunto. Isso amplia as etapas `workspace --claude` hospeda o binário Claude Code original em PTY oculto, usa hooks para conversa e permissões na Bee e retoma o ID nativo. Mensagem real curta comprovada; prompts nativos sem hook são sinalizados e concluídos por - `Ctrl+O` (#29); ferramentas/perfis reais ainda exigem ensaio humano. + `Ctrl+O` (#29); a origem das ações é explícita (#30); histórico e exportação + vêm da transcrição nativa (#31). Ferramentas/perfis reais exigem ensaio humano. 3. Experiência integrada aos dois harnesses, histórico e exportação: pendente. 4. Troca real entre agentes/contas com revisão e preservação da origem: pendente. 5. Lançamento conjunto após todos os aceites, sem substituir o requisito por diff --git a/docs/EXECUTION.md b/docs/EXECUTION.md index d28aba0..760efb3 100644 --- a/docs/EXECUTION.md +++ b/docs/EXECUTION.md @@ -80,7 +80,8 @@ e a Bee distingue ações aprovadas por ela das liberadas pelo próprio Claude. Próximo: ensaio humano de ferramentas/permissões reais ([#30](https://github.com/lippdev/memory-bee/issues/30)) e histórico/exportação revisável ([#31](https://github.com/lippdev/memory-bee/issues/31)). -O modo ainda guarda só IDs; perfis reais e Codex integrado seguem pendentes. +O estado ainda guarda só IDs; histórico e exportação vêm da transcrição nativa +(#31, PR em andamento). Perfis reais e Codex integrado seguem pendentes. O lançamento da experiência completa ainda exige os dois agentes (ADR 0018). **Refinamento visual.** Referência HTML e [plano visual](specs/workspace-visual.md) @@ -816,3 +817,31 @@ escopo da entrega atual nem a próxima tarefa aprovada. redesenha antes de encerrar. - Próximo: ensaio humano do item 52 para fechar #29/#30 e, em código, #31 (histórico e exportação da sessão Claude). + +## Claude oculto — histórico e exportação da sessão nativa (#31, código) + +- Data: 2026-09-28. PR #51 (#29/#30) integrado por squash em `6b7fe2b`; este + recorte recomeçou o branch da sessão a partir da `main`. +- A transcrição é a informada pelo próprio Claude em `transcript_path` dos + hooks, validada como `.jsonl` regular sem symlink; o estado continua sem + caminho nem conteúdo. Na retomada, a Bee relê o ramo único pelo leitor Claude + existente e mostra até 200 linhas marcadas `↺`, com omissões, leitura parcial + ou ramos múltiplos informados; nada é reenviado. `Ctrl+E` prepara um pacote + v1 somente contexto com referência Git do projeto, prévia com linhas da + transcrição, avisos, omissões e segredos possíveis (texto oculto), exclusões + por linha, rolagem, e `Ctrl+S` grava em `/exports/` (0700) e roda + `verify`. Ao sair, a CLI imprime o comando de exportação posterior. +- Evidências: testes de biblioteca (validação do caminho, histórico básico, + limite com omissão, transcrição truncada, ramos, exportação verificável, + destino não reutilizado, exclusão); teste unitário da TUI bloqueando possível + segredo até a exclusão da linha e sem vazar o token na prévia (o teste pegou um + vazamento na primeira versão da lista de registros, corrigido); teste PTY com + CLI falsa reportando `transcript_path`: exportação por `Ctrl+E`/`Ctrl+S`, + `memory-bee verify` do pacote, retomada com histórico e nenhum texto reenviado + ao Claude. Telas conferidas em emulador (`pyte`). Checks canônicos, scripts PTY + e `check_bundle.py` passaram localmente (Linux). Autorrevisão. +- Limitações: sem Claude autenticado neste ambiente, a reidratação e a exportação + não foram ensaiadas com transcrição real (item 54; depende também de #25). + Exportação após o Claude encerrar é feita pela CLI `export`, não pela TUI. + Código selecionado (v2) não é oferecido na TUI. +- Próximo: ensaios humanos 52 e 54; depois Codex real no workspace (ADR 0018). diff --git a/docs/MANUAL_TESTS.md b/docs/MANUAL_TESTS.md index 0636186..b77e98e 100644 --- a/docs/MANUAL_TESTS.md +++ b/docs/MANUAL_TESTS.md @@ -932,3 +932,23 @@ não contar esta seção como executada pelo resultado de CI deste ajuste. | Item novo | Estado | Evidência manual | |---|---|---| | 53: revisão inicial, incremental e rascunho | Pendente | — | + +## 54. Histórico e exportação do Claude oculto (pendente) + +Em um projeto descartável já confiado, com Git, conversar com +`workspace --claude` (inclua uma leitura e uma execução), sair com `Ctrl+Q` e +retomar com `--resume`. Esperado: bloco "Histórico da transcrição do Claude (não +reenviado)" com as mensagens e ações anteriores marcadas com `↺`, sem nova +resposta do Claude nem prompt reenviado. Pressionar `Ctrl+E`: conferir destino +em `/exports/`, registros com linha, avisos e omissões; digitar uma linha +em "Excluir linhas", `Enter`, e conferir que ela sai da lista; `Ctrl+S` deve +informar "Pacote salvo e verificado". Rodar `memory-bee verify ` e ler +`HANDOFF.md`/`history.jsonl` sem a Memory Bee. Repetir após interromper um turno +com `Ctrl+C` e após um erro. Colar um token falso no formato `sk-` seguido de 24 +letras numa mensagem: a prévia deve ocultar o texto daquela linha e `Ctrl+S` +recusar até a linha ser excluída. Ao sair, conferir o comando de exportação +impresso. Não versionar pacotes com dados reais. + +| Item novo | Estado | Evidência manual | +|---|---|---| +| 54: reidratação na retomada, exportação com exclusões, segredos e verify | Pendente | — | diff --git a/docs/specs/workspace.md b/docs/specs/workspace.md index b13d0eb..2dafe64 100644 --- a/docs/specs/workspace.md +++ b/docs/specs/workspace.md @@ -74,8 +74,23 @@ sessões, 64 KiB), sem transcrição. A pasta é 0700 e arquivos são 0600 em Un `claude-native.lock` impede segundo escritor. Após crash, verificar que não há processo ativo antes de remover a trava antiga. `--resume` usa o último ID desta pasta; não reenvia prompt. A sessão nativa pode não existir quando se sai antes -de enviar uma mensagem. Exportação/revisão de memória, isolamento de perfis e -integração Codex estão pendentes. O modo não comprova +de enviar uma mensagem. Isolamento de perfis e integração Codex estão pendentes. + +**Histórico e exportação (#31).** A transcrição vem do campo `transcript_path` +dos hooks oficiais; a Bee aceita só arquivo comum, sem symlink, chamado +`.jsonl`, e não guarda o caminho no estado. Na retomada, ao receber +`SessionStart`, o leitor Claude existente relê o ramo único da transcrição e +mostra até 200 linhas marcadas com `↺` (mensagens, nomes de ações e resumos de +compactação), informando quantas ficaram só na transcrição, leitura parcial ou +ramos múltiplos; nada é reenviado nem inventado. `Ctrl+E` abre a prévia da +exportação somente contexto (pacote v1 com referência Git do projeto): destino +novo em `/exports/` (0700), registros com a linha da transcrição, avisos, +omissões e possíveis segredos, cujo texto fica oculto na prévia. O campo de +exclusões aceita linhas separadas por vírgula (`Enter` aplica, setas/PgUp/PgDn +rolam); `Ctrl+S` grava o pacote preparado e executa `verify`, recusando gravar +enquanto houver possível segredo. Funciona durante a sessão, inclusive após +erro, interrupção ou limite; ao sair, a CLI imprime o comando `memory-bee export + --preview` para exportar depois. O modo não comprova conversa/permissões reais por si só; ver [roteiro manual](../MANUAL_TESTS.md). **Direção atual:** o mantenedor não quer ver a TUI original do Claude. O modo diff --git a/scripts/check_claude_hidden_pty.py b/scripts/check_claude_hidden_pty.py index 9592c86..a51979e 100644 --- a/scripts/check_claude_hidden_pty.py +++ b/scripts/check_claude_hidden_pty.py @@ -63,6 +63,45 @@ def hook(name, **fields): sys.exit(0) ''' +# Resumable session: reports its transcript through the official hook field. +FAKE_HISTORY = r'''#!/usr/bin/env python3 +import json, os, shutil, subprocess, sys +args = sys.argv[1:] +resume = '--resume' in args +session_id = args[args.index('--resume') + 1] if resume else args[args.index('--session-id') + 1] +transcript = os.path.join(os.environ['BEE_TRANSCRIPTS'], session_id + '.jsonl') +shutil.copy(os.environ['BEE_FIXTURE'], transcript) +payload = json.dumps(dict(hook_event_name='SessionStart', session_id=session_id, transcript_path=transcript, source='resume' if resume else 'startup')) +subprocess.run([os.environ['BEE_BINARY'], '__claude-hook'], input=payload, text=True, capture_output=True, check=True) +for line in sys.stdin: + with open(os.environ['BEE_FAKE_STDIN'], 'a') as f: + f.write(line.strip() + '\n') + if line.strip() == '/exit': + sys.exit(0) +''' + + +def history_and_export(binary, project, state, env, resume): + master, slave, before, proc, output, until = spawn(binary, project, state, env, resume) + try: + if resume: + until(b'decrescente') # Previous user message read back from the transcript. + else: + until(b'pronto') + os.write(master, b'\x05') # Ctrl+E opens the export preview. + until(b'Destino') + os.write(master, b'\x13') # Ctrl+S writes and verifies. + until(b'verificado') + os.write(master, b'\x11') + assert drain_until_exit(master, proc, output, 10) == 0 + assert termios.tcgetattr(slave) == before + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + os.close(master) + os.close(slave) + def drain_until_exit(master, proc, output, seconds): """Keep reading while waiting: a full PTY buffer (small on macOS) would @@ -79,11 +118,13 @@ def drain_until_exit(master, proc, output, seconds): return proc.returncode -def spawn(binary, project, state, env): +def spawn(binary, project, state, env, resume=False): master, slave = pty.openpty() fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0)) before = termios.tcgetattr(slave) argv = [str(binary), 'workspace', '--claude', '--project', str(project), '--state', str(state), '--no-color'] + if resume: + argv.append('--resume') proc = subprocess.Popen(argv, stdin=slave, stdout=slave, stderr=slave, env=env) output = bytearray() @@ -227,7 +268,30 @@ def main(): assert not stdin_log.exists(), 'Ctrl+Q typed into the native dialog' untrusted(binary, project, root / 'state-setup', env, False) assert stdin_log.read_text().splitlines() == ['1', '/exit'] - print('PASS: Bee-only UI, hidden original output, hooks, deny/allow, Ctrl+Q denial, resume, blocked native setup via Ctrl+O, safe Ctrl+Q and terminal restoration') + with tempfile.TemporaryDirectory(prefix='bee-history-') as temp: + root = Path(temp) + fake_dir = root / 'bin' + fake_dir.mkdir() + fake = fake_dir / 'claude' + fake.write_text(FAKE_HISTORY) + fake.chmod(0o700) + project = root / 'project' + project.mkdir() + transcripts = root / 'transcripts' + transcripts.mkdir() + state = root / 'state' + stdin_log = root / 'stdin' + fixture = Path(__file__).resolve().parent.parent / 'testdata/claude/basic.jsonl' + env = dict(os.environ, PATH=f'{fake_dir}:/usr/bin:/bin', BEE_BINARY=str(binary), BEE_FAKE_STDIN=str(stdin_log), BEE_TRANSCRIPTS=str(transcripts), BEE_FIXTURE=str(fixture)) + history_and_export(binary, project, state, env, False) + bundles = list((state / 'exports').iterdir()) + assert len(bundles) == 1, bundles + verified = subprocess.run([str(binary), 'verify', str(bundles[0])], capture_output=True, text=True) + assert verified.returncode == 0, verified.stdout + verified.stderr + history_and_export(binary, project, state, env, True) + # History is shown from the transcript, never typed back into Claude. + assert stdin_log.read_text().splitlines() == ['/exit', '/exit'] + print('PASS: Bee-only UI, hidden original output, hooks, deny/allow, Ctrl+Q denial, resume, blocked native setup via Ctrl+O, safe Ctrl+Q, history rehydration, verified export and terminal restoration') if __name__ == '__main__': diff --git a/src/main.rs b/src/main.rs index 8b13dc9..b740f57 100644 --- a/src/main.rs +++ b/src/main.rs @@ -622,7 +622,7 @@ fn workspace(args: &[std::ffi::OsString]) -> Result { } let state_path = state.ok_or((64, "workspace --claude exige --state ".into()))?; - let (id, code) = if claude_terminal { + let (id, code, transcript) = if claude_terminal { tui::claude_native::run(Path::new(&project), &state_path, resume, mode, no_color) } else { #[cfg(unix)] @@ -642,6 +642,12 @@ fn workspace(args: &[std::ffi::OsString]) -> Result { } .map_err(|e| (1, e))?; println!("Claude Code encerrou. Sessão nativa: {id}"); + if let Some(transcript) = transcript { + println!( + "Para exportar depois: memory-bee export {} --preview", + transcript.display() + ); + } return Ok(if code == 0 { 0 } else { 4 }); } if once { diff --git a/src/tui/claude_native.rs b/src/tui/claude_native.rs index fb7df44..99e5ed5 100644 --- a/src/tui/claude_native.rs +++ b/src/tui/claude_native.rs @@ -8,7 +8,7 @@ use crossterm::{ }, terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, }; -use memory_bee::workspace::claude_native::ClaudeStore; +use memory_bee::workspace::claude_native::{self as native, ClaudeStore}; use portable_pty::{CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem}; use ratatui::{ Frame, Terminal, @@ -20,7 +20,7 @@ use ratatui::{ use std::{ ffi::OsStr, io::{self, BufRead, Read, Write}, - path::Path, + path::{Path, PathBuf}, sync::mpsc::{self, Receiver}, thread, time::{Duration, Instant}, @@ -400,7 +400,7 @@ pub fn run( resume: bool, mode: Mode, no_color: bool, -) -> Result<(Uuid, u32), String> { +) -> Result<(Uuid, u32, Option), String> { let (store, mut state) = ClaudeStore::open(root, project)?; let id = if resume { state @@ -505,7 +505,7 @@ pub fn run( } let code = status.unwrap_or(1); store.finish(&mut state, id, code)?; - Ok((id, code)) + Ok((id, code, None)) } /// The hook denies a request left unanswered this long. @@ -767,11 +767,206 @@ fn tool_summary(input: &serde_json::Value) -> String { } } +/// Newest history lines shown after a resume; older ones stay in the transcript. +#[cfg(unix)] +const HISTORY_LINES: usize = 200; + +/// Export review: the preview and the write use the same prepared bytes. +#[cfg(unix)] +struct Export { + destination: PathBuf, + exclusions: String, + prepared: Result, + result: Option, + scroll: u16, +} + +#[cfg(unix)] +fn parse_exclusions(text: &str) -> Result, String> { + text.split(',') + .map(str::trim) + .filter(|part| !part.is_empty()) + .map(|part| match part.parse::() { + Ok(line) if line > 0 => Ok(line), + _ => Err(format!("Linha inválida: {part}")), + }) + .collect() +} + +#[cfg(unix)] +impl Export { + fn open(transcript: &Path, project: &Path, destination: PathBuf) -> Self { + let mut export = Self { + destination, + exclusions: String::new(), + prepared: Err(String::new()), + result: None, + scroll: 0, + }; + export.prepare(transcript, project); + export + } + fn prepare(&mut self, transcript: &Path, project: &Path) { + self.result = None; + self.prepared = parse_exclusions(&self.exclusions) + .and_then(|lines| native::prepare_export(transcript, Some(project), lines)); + } + fn summary(&self) -> String { + let prepared = match &self.prepared { + Ok(prepared) => prepared, + Err(e) => return format!("Não foi possível preparar: {e}"), + }; + let manifest = serde_json::to_value(prepared.manifest()).unwrap_or_default(); + let list = |key: &str| -> Vec { + manifest[key] + .as_array() + .map(|items| { + items + .iter() + .filter_map(|v| v.as_str().map(|s| format!(" · {s}"))) + .collect() + }) + .unwrap_or_default() + }; + let mut text = vec![ + format!("Destino novo: {}", self.destination.display()), + format!( + "Eventos no histórico: {}{}", + prepared.history().lines().count(), + if prepared.is_partial() { + " · pacote parcial" + } else { + "" + } + ), + "Somente contexto; código não é incluído. Sem chamadas a modelos.".into(), + "Registros (linha da transcrição · papel · início do texto):".into(), + ]; + let flagged: std::collections::BTreeSet = + prepared.findings().iter().filter_map(|f| f.line).collect(); + for line in prepared.history().lines() { + let event: serde_json::Value = serde_json::from_str(line).unwrap_or_default(); + let number = event["source"]["line"].as_u64().unwrap_or(0); + if flagged.contains(&(number as usize)) { + text.push(format!( + " {number:>4} · {} · [texto oculto: possível segredo]", + event["role"].as_str().unwrap_or("?") + )); + continue; + } + let start: String = event["text"] + .as_str() + .unwrap_or("") + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(60) + .collect(); + text.push(format!( + " {number:>4} · {} · {start}", + event["role"].as_str().unwrap_or("?"), + )); + } + if !prepared.findings().is_empty() { + text.push("Possíveis segredos (exclua as linhas para exportar):".into()); + for finding in prepared.findings() { + text.push(format!( + " · linha {} · {} · {}", + finding + .line + .map_or_else(|| "?".into(), |line| line.to_string()), + finding.field, + finding.code + )); + } + } + // A finding outside the records (e.g. a branch ID) may appear in the + // warnings verbatim, so they are withheld instead of shown. + let unlocated: Vec<&str> = prepared + .findings() + .iter() + .filter(|f| f.line.is_none()) + .map(|f| f.field) + .collect(); + let warnings = list("warnings"); + if !unlocated.is_empty() { + text.push(format!( + "Avisos ocultos na prévia: possível segredo fora dos registros ({})", + unlocated.join(", ") + )); + } else if !warnings.is_empty() { + text.push("Avisos:".into()); + text.extend(warnings); + } + let omissions = list("omissions"); + if !omissions.is_empty() { + text.push("Omissões:".into()); + text.extend(omissions); + } + text.join("\n") + } + /// Writes a new directory, then verifies it like `memory-bee verify`. + fn write(&mut self) { + let result = match &self.prepared { + Err(e) => Err(format!("Nada exportado: {e}")), + Ok(prepared) if !prepared.findings().is_empty() => { + Err("Nada exportado: exclua as linhas com possíveis segredos".into()) + } + Ok(prepared) => private_parent(&self.destination) + .and_then(|()| { + prepared + .write(&self.destination) + .map_err(|e| format!("Falha ao gravar: {e}")) + }) + .and_then(|()| { + memory_bee::receive::verify(&self.destination) + .map(|_| ()) + .map_err(|e| format!("Gravado, mas verify falhou: {e}")) + }) + .map(|()| { + format!( + "Pacote salvo e verificado em {}", + self.destination.display() + ) + }), + }; + self.result = Some(result.unwrap_or_else(|e| e)); + } +} + +/// The default export folder lives in the private state directory. +#[cfg(unix)] +fn private_parent(destination: &Path) -> Result<(), String> { + let Some(parent) = destination.parent() else { + return Ok(()); + }; + if parent.exists() { + let meta = std::fs::symlink_metadata(parent).map_err(|e| e.to_string())?; + return if meta.is_dir() && !meta.file_type().is_symlink() { + Ok(()) + } else { + Err("Pasta de exportação deve ser pasta real, sem symlink".into()) + }; + } + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new() + .mode(0o700) + .create(parent) + .map_err(|e| e.to_string()) +} + #[cfg(unix)] struct HiddenView { lines: Vec, input: String, pending: Option, + /// Transcript reported by Claude's hooks; source of history and export. + transcript: Option, + /// Resumed session whose history is read back once from the transcript. + resume: bool, + rehydrated: bool, + export: Option, /// Recent tool calls, to tell Bee approvals from Claude's own rules. tools: Vec, next_tool: u64, @@ -795,6 +990,10 @@ impl HiddenView { lines: vec!["Iniciando Claude Code em segundo plano…".into()], input: String::new(), pending: None, + transcript: None, + resume: false, + rehydrated: false, + export: None, tools: Vec::new(), next_tool: 0, ready: false, @@ -853,6 +1052,33 @@ impl HiddenView { } false } + /// Shows the previous conversation from the native transcript. Nothing + /// is resent to Claude and nothing absent from the transcript is shown. + fn rehydrate(&mut self) { + let Some(path) = self.transcript.clone() else { + self.push("Histórico anterior indisponível: o Claude não informou a transcrição."); + return; + }; + let history = native::history(&path, HISTORY_LINES); + if history.lines.is_empty() && history.note.is_none() { + self.push("Sessão retomada sem mensagens anteriores na transcrição."); + return; + } + self.push("── Histórico da transcrição do Claude (não reenviado) ──"); + if history.omitted > 0 { + self.push(format!( + "↺ {} registros anteriores ficam só na transcrição", + history.omitted + )); + } + for line in history.lines { + self.push(format!("↺ {line}")); + } + if let Some(note) = history.note { + self.push(format!("↺ {note}")); + } + self.push("── Fim do histórico; novos eventos abaixo ──"); + } fn submitted(&mut self, now: Instant) { self.turn = Some(now); self.turn_hinted = false; @@ -954,12 +1180,24 @@ impl HiddenView { self.turn = Some(now); self.turn_hinted = false; } + if self.transcript.is_none() + && let Some(reported) = event.value["transcript_path"].as_str() + { + match native::transcript_path(reported, id) { + Ok(path) => self.transcript = Some(path), + Err(e) => self.push(format!("Histórico/exportação indisponíveis: {e}")), + } + } match event.value["hook_event_name"].as_str().unwrap_or("") { "SessionStart" => { self.ready = true; if self.blocked.take().is_some() { self.push("Preparação nativa concluída. Ctrl+O volta à Bee se necessário."); } + if self.resume && !self.rehydrated { + self.rehydrated = true; + self.rehydrate(); + } self.push("Claude pronto. Digite sua mensagem abaixo."); } "MessageDisplay" => { @@ -1151,11 +1389,57 @@ fn draw_hidden(frame: &mut Frame, view: &HiddenView, mode: Mode, no_color: bool) ); frame.render_widget( Paragraph::new( - "Enter envia · Ctrl+C interrompe · Ctrl+O Claude original · Ctrl+Q sai · sem aprovação Bee, negar", + "Enter envia · Ctrl+E exporta · Ctrl+C interrompe · Ctrl+O original · Ctrl+Q sai", ) - .style(Style::default().bg(bg)), + .style(Style::default().bg(bg)), rows[3], ); + if let (None, Some(export)) = (&view.pending, &view.export) { + let modal = Rect::new( + 2, + 1, + area.width.saturating_sub(4), + area.height.saturating_sub(2), + ); + frame.render_widget(ratatui::widgets::Clear, modal); + frame.render_widget( + Block::default() + .borders(Borders::ALL) + .title(" Exportar sessão Claude · prévia ") + .style(Style::default().bg(bg)) + .border_style(Style::default().fg(palette.accent)), + modal, + ); + let mut body = export.summary(); + if let Some(result) = &export.result { + body = format!("{result}\n\n{body}"); + } + frame.render_widget( + Paragraph::new(body) + .wrap(ratatui::widgets::Wrap { trim: false }) + .scroll((export.scroll, 0)) + .style(Style::default().bg(bg)), + Rect::new( + modal.x + 2, + modal.y + 1, + modal.width.saturating_sub(4), + modal.height.saturating_sub(5), + ), + ); + frame.render_widget( + Paragraph::new(format!( + "Excluir linhas: {}\nEnter aplica · ↑↓ PgUp PgDn rolam · Ctrl+S exporta · Esc fecha", + export.exclusions + )) + .style(Style::default().bg(bg)), + Rect::new( + modal.x + 2, + modal.bottom().saturating_sub(3), + modal.width.saturating_sub(4), + 2, + ), + ); + } if let Some(Pending { request, .. }) = &view.pending { let modal = Rect::new( 2, @@ -1251,7 +1535,7 @@ pub fn run_hidden( resume: bool, mode: Mode, no_color: bool, -) -> Result<(Uuid, u32), String> { +) -> Result<(Uuid, u32, Option), String> { let (store, mut state) = ClaudeStore::open(root, project)?; let id = if resume { state @@ -1287,6 +1571,7 @@ pub fn run_hidden( store.start(&mut state, id)?; } let mut view = HiddenView::new(Instant::now()); + view.resume = resume; let mut status = None; let mut quit_at = None; let mut dirty = true; @@ -1377,6 +1662,62 @@ pub fn run_hidden( view.native = false; } else if control && key.code == KeyCode::Char('o') && view.pending.is_none() { view.native = !view.native; + } else if control + && key.code == KeyCode::Char('e') + && view.pending.is_none() + && !view.native + { + if view.export.take().is_none() { + match &view.transcript { + Some(transcript) => { + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()); + let destination = + root.join("exports").join(format!("claude-{id}-{stamp}")); + view.export = + Some(Export::open(transcript, project, destination)); + } + None => view.push( + "Exportação indisponível: o Claude ainda não informou a transcrição.", + ), + } + } + } else if view.export.is_some() + && view.pending.is_none() + && !view.native + && !(control && key.code == KeyCode::Char('c')) + { + let export = view.export.as_mut().expect("checked export"); + match key.code { + KeyCode::Esc => view.export = None, + KeyCode::Char('s') if control => { + export.write(); + let result = export.result.clone().unwrap_or_default(); + view.push(result); + } + KeyCode::Char(c) + if !control && (c.is_ascii_digit() || c == ',' || c == ' ') => + { + export.exclusions.push(c) + } + KeyCode::Backspace => { + export.exclusions.pop(); + } + KeyCode::Up => export.scroll = export.scroll.saturating_sub(1), + KeyCode::Down => export.scroll = export.scroll.saturating_add(1), + KeyCode::PageUp => export.scroll = export.scroll.saturating_sub(10), + KeyCode::PageDown => export.scroll = export.scroll.saturating_add(10), + KeyCode::Enter => { + if let Some(transcript) = view.transcript.clone() { + view.export + .as_mut() + .expect("checked export") + .prepare(&transcript, project); + } + } + _ => {} + } } else if view.native { if let Some(bytes) = key_bytes(key, parser.screen().application_cursor()) { pty.write(&bytes)?; @@ -1445,7 +1786,7 @@ pub fn run_hidden( view.decide(false, false); let code = status.unwrap_or(1); store.finish(&mut state, id, code)?; - Ok((id, code)) + Ok((id, code, view.transcript)) } #[cfg(test)] @@ -1670,6 +2011,68 @@ mod tests { } #[cfg(unix)] #[test] + fn export_blocks_possible_secrets_until_their_lines_are_excluded() { + let root = std::env::temp_dir().join(format!("bee-export-{}", Uuid::new_v4())); + std::fs::create_dir(&root).unwrap(); + let transcript = root.join("session.jsonl"); + // Synthetic, non-working token shape. + let token = format!("sk-{}", "x".repeat(24)); + std::fs::write( + &transcript, + format!( + "{}\n{}\n", + serde_json::json!({"type": "user", "uuid": "u1", "parentUuid": null, "sessionId": "s", "message": {"role": "user", "content": "Olá"}}), + serde_json::json!({"type": "assistant", "uuid": "a1", "parentUuid": "u1", "sessionId": "s", "message": {"role": "assistant", "content": [{"type": "text", "text": token}]}}), + ), + ) + .unwrap(); + let destination = root.join("exports").join("bundle"); + let mut export = Export::open(&transcript, &root, destination.clone()); + assert!(export.summary().contains("linha 2")); + assert!( + !export.summary().contains(&token), + "preview lists findings, not the secret" + ); + export.write(); + assert!( + export + .result + .as_deref() + .unwrap() + .starts_with("Nada exportado") + ); + assert!(!destination.exists()); + export.exclusions = "2".into(); + export.prepare(&transcript, &root); + export.write(); + assert!( + export.result.as_deref().unwrap().contains("verificado"), + "{:?}", + export.result + ); + let written = std::fs::read_to_string(destination.join("history.jsonl")).unwrap(); + assert!(!written.contains(&token)); + export.exclusions = "0".into(); + export.prepare(&transcript, &root); + assert!(export.summary().contains("Linha inválida")); + // A secret-shaped branch ID has no line: warnings are withheld. + let branch = root.join("branch.jsonl"); + std::fs::write( + &branch, + format!( + "{}\n", + serde_json::json!({"type": "user", "uuid": token, "parentUuid": null, "sessionId": "s", "message": {"role": "user", "content": "Olá"}}), + ), + ) + .unwrap(); + let export = Export::open(&branch, &root, root.join("exports").join("other")); + let summary = export.summary(); + assert!(summary.contains("Avisos ocultos"), "{summary}"); + assert!(!summary.contains(&token), "{summary}"); + std::fs::remove_dir_all(root).unwrap(); + } + #[cfg(unix)] + #[test] fn fake_cli_runs_in_pty_and_echoes_input() { use std::{fs, os::unix::fs::PermissionsExt}; let root = std::env::temp_dir().join(format!("bee-pty-{}", Uuid::new_v4())); diff --git a/src/workspace/claude_native.rs b/src/workspace/claude_native.rs index 95c9289..6b176a3 100644 --- a/src/workspace/claude_native.rs +++ b/src/workspace/claude_native.rs @@ -172,6 +172,106 @@ impl Drop for ClaudeStore { } } +/// Transcript reported by Claude's own `SessionStart` hook. Only a regular, +/// non-symlink `.jsonl` is accepted; nothing is guessed. +pub fn transcript_path(reported: &str, id: Uuid) -> Result { + let path = PathBuf::from(reported); + if !path.is_absolute() || path.file_name() != Some(format!("{id}.jsonl").as_ref()) { + return Err("Transcrição informada pelo Claude não corresponde à sessão".into()); + } + let meta = fs::symlink_metadata(&path).map_err(|_| "Transcrição Claude indisponível")?; + if !meta.is_file() || meta.file_type().is_symlink() { + return Err("Transcrição Claude deve ser arquivo comum, sem symlink".into()); + } + Ok(path) +} + +/// Previous conversation read back from the native transcript, never resent. +#[derive(Debug, Default)] +pub struct History { + pub lines: Vec, + /// Older entries not shown on screen; they stay in the transcript. + pub omitted: usize, + pub partial: bool, + /// Why nothing (or not everything) could be shown. + pub note: Option, +} + +fn one_line(text: &str, max: usize) -> String { + let line: String = text.split_whitespace().collect::>().join(" "); + if line.chars().count() > max { + format!("{}…", line.chars().take(max).collect::()) + } else { + line + } +} + +fn selected(path: &Path) -> Result { + let report = crate::claude::inspect(path, crate::claude::Limits::default()) + .map_err(|e| format!("Transcrição Claude ilegível: {e}"))?; + if report.events.is_empty() { + return Ok(report); + } + if report.requires_branch_selection || report.branch_tips.len() != 1 { + return Err("Transcrição com mais de um ramo; use memory-bee export --leaf".into()); + } + let leaf = report.branch_tips[0].clone(); + crate::selection::select(report, &leaf).map_err(str::to_owned) +} + +/// Extracted user/assistant text and tool names of the selected branch. The +/// newest `max` lines are kept; the rest is counted, not dropped silently. +pub fn history(path: &Path, max: usize) -> History { + let report = match selected(path) { + Ok(report) => report, + Err(note) => { + return History { + note: Some(note), + ..History::default() + }; + } + }; + let partial = report.state == crate::claude::ReadState::Partial; + let mut lines: Vec = report + .events + .iter() + .filter_map(|event| match (event.role, event.kind) { + ("user", "text") => Some(format!("Você: {}", one_line(&event.text, 400))), + ("assistant", "text") => Some(format!("Claude: {}", one_line(&event.text, 400))), + (_, "tool_call") => Some(format!( + "Ação: {}", + event.tool_name.as_deref().unwrap_or("desconhecida") + )), + (_, "checkpoint") => Some("Resumo de compactação do Claude".into()), + _ => None, + }) + .collect(); + let omitted = lines.len().saturating_sub(max); + lines.drain(..omitted); + History { + lines, + omitted, + partial, + note: partial.then(|| "Transcrição parcial; registros ilegíveis foram omitidos".into()), + } +} + +/// Context-only bundle of the native session, prepared from the same bytes +/// the preview shows. Git reference is included when `project` is given. +pub fn prepare_export( + path: &Path, + project: Option<&Path>, + exclude_lines: std::collections::BTreeSet, +) -> Result { + let report = selected(path)?; + let options = crate::bundle::Options { + project: project.map(Path::to_path_buf), + exclude_lines, + ..crate::bundle::Options::default() + }; + crate::bundle::prepare(report, &options) +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/workspace.rs b/tests/workspace.rs index 66ff31d..7d96977 100644 --- a/tests/workspace.rs +++ b/tests/workspace.rs @@ -404,3 +404,81 @@ fn transport_uses_explicit_profile_and_bounds_invalid_frames() { fs::set_permissions(&home, fs::Permissions::from_mode(0o755)).unwrap(); assert!(Transport::spawn(&fake, &home, &temp.0).is_err()); } + +mod claude_transcript { + use super::Temp; + use memory_bee::{receive, workspace::claude_native as native}; + use std::{collections::BTreeSet, fs, path::Path}; + use uuid::Uuid; + + fn copy(temp: &Temp, fixture: &str, id: Uuid) -> String { + let path = temp.0.join(format!("{id}.jsonl")); + fs::copy( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("testdata/claude") + .join(fixture), + &path, + ) + .unwrap(); + path.to_str().unwrap().to_owned() + } + + #[test] + fn only_the_reported_session_transcript_is_accepted() { + let temp = Temp::new(); + let id = Uuid::new_v4(); + let path = copy(&temp, "basic.jsonl", id); + assert!(native::transcript_path(&path, id).is_ok()); + assert!(native::transcript_path(&path, Uuid::new_v4()).is_err()); + assert!(native::transcript_path(&format!("{id}.jsonl"), id).is_err()); + #[cfg(unix)] + { + let other = Uuid::new_v4(); + let link = temp.0.join(format!("{other}.jsonl")); + std::os::unix::fs::symlink(&path, &link).unwrap(); + assert!(native::transcript_path(link.to_str().unwrap(), other).is_err()); + } + } + + #[test] + fn history_is_read_back_without_inventing_or_hiding_losses() { + let temp = Temp::new(); + let id = Uuid::new_v4(); + let basic = copy(&temp, "basic.jsonl", id); + let history = native::history(Path::new(&basic), 200); + assert_eq!(history.lines.len(), 2); + assert!(history.lines[0].starts_with("Você: Adicione ordenação")); + assert!(history.lines[1].starts_with("Claude: ")); + assert!(!history.partial && history.note.is_none() && history.omitted == 0); + let newest = native::history(Path::new(&basic), 1); + assert_eq!((newest.lines.len(), newest.omitted), (1, 1)); + assert!(newest.lines[0].starts_with("Claude: ")); + + let truncated = copy(&temp, "truncated.jsonl", Uuid::new_v4()); + let history = native::history(Path::new(&truncated), 200); + assert!(history.partial && history.note.is_some()); + + let branches = copy(&temp, "branches.jsonl", Uuid::new_v4()); + let history = native::history(Path::new(&branches), 200); + assert!(history.lines.is_empty()); + assert!(history.note.unwrap().contains("ramo")); + } + + #[test] + fn native_session_exports_a_verifiable_context_bundle() { + let temp = Temp::new(); + let transcript = copy(&temp, "tools.jsonl", Uuid::new_v4()); + let prepared = + native::prepare_export(Path::new(&transcript), Some(&temp.0), BTreeSet::new()).unwrap(); + assert!(prepared.findings().is_empty()); + // Outside Git the reference is partial and says so; nothing is invented. + assert!(prepared.is_partial()); + let out = temp.0.join("bundle"); + prepared.write(&out).unwrap(); + receive::verify(&out).unwrap(); + assert!(prepared.write(&out).is_err(), "never reuses a destination"); + let excluded = + native::prepare_export(Path::new(&transcript), None, BTreeSet::from([1])).unwrap(); + assert!(excluded.history().lines().count() < prepared.history().lines().count()); + } +}