diff --git a/Cargo.lock b/Cargo.lock index 5d177cd..5f25b28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2788,6 +2788,16 @@ dependencies = [ name = "recall-probe" version = "0.1.0" +[[package]] +name = "recall-reflect" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "serde", + "serde_json", +] + [[package]] name = "redox_syscall" version = "0.5.18" diff --git a/Cargo.toml b/Cargo.toml index 059c486..90c542e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ repository = "https://github.com/samzong/Recall" publish = false [workspace] -members = [".", "extensions/recall-probe"] +members = [".", "extensions/recall-probe", "extensions/recall-reflect"] default-members = ["."] resolver = "2" diff --git a/docs/extensions/reflect.md b/docs/extensions/reflect.md new file mode 100644 index 0000000..9af7c08 --- /dev/null +++ b/docs/extensions/reflect.md @@ -0,0 +1,395 @@ +# recall-reflect PRD + +## Goal + +Define the official Reflect extension so Recall can turn local AI coding history +into a clean timeline of human intent, agent response, corrections, and +follow-up work. + +Reflect is designed as the official `recall-reflect` extension. Recall core provides session data through the stable CLI JSON/JSONL protocol; the extension owns timeline reconstruction, observed pattern prompts, and future discussion/calibration workflow. + +The primary workflow is: + +1. A user or coding agent selects a repository root or repository identity. +2. Recall core finds relevant local sessions for that project. +3. `recall-reflect` reconstructs a conversation-first timeline across those sessions. +4. `recall-reflect` surfaces observed workflow patterns as discussion prompts. +5. The user decides whether any pattern should become a workflow, skill, agent, + or instruction-file change. +6. If the user approves, the extension can prepare an explicit proposal or patch for a + later apply step. + +Reflect is not only a report. The complete product direction is an extension-led reflection and +calibration loop: inspect the timeline, discuss what the timeline means, propose +changes only after user confirmation, and later compare new sessions against +accepted changes. + +## Problem + +Recall already supports local session indexing, search, export, sharing, usage +reporting, and session-level actions. Those workflows are session-first: they +help users find, inspect, and reuse individual conversations. + +AI coding work often does not stay inside one session. A project may move from +Claude Code to Codex, from Codex to OpenCode, or from a long exploration session +to several focused follow-up sessions. Users also copy agent output into new +prompts, restart with a cleaner context, retry failed approaches, or gradually +turn repeated friction into a personal workflow. + +That makes reflection different from search. The useful unit is the project +timeline, not the individual session. A session-first summary can miss the +larger pattern: how work moved, where misunderstandings repeated, when the user +changed direction, and which behaviors might be worth preserving or changing. + +## Users + +- Power users who want to understand and improve their AI-assisted coding + workflow from local history. +- Coding agents that need a clean project timeline before proposing workflow or + skill changes. +- Maintainers who want Recall's reflection behavior to stay local-first, + explicit, and reviewable. + +## Definitions + +- **Reflect**: the official `recall-reflect` extension, a project-level workflow + that reconstructs a timeline, surfaces patterns for discussion, and can later + produce user-approved change proposals. +- **Project timeline**: a chronological narrative built from multiple sessions + that belong to the same repository or project scope. +- **Conversation-first timeline**: a timeline focused on user inputs, agent + responses, decisions, corrections, and outcomes. Low-level tool calls and raw + execution logs are supporting data, not the default narrative. +- **Observed pattern**: a repeated or notable behavior found in the timeline, + such as a handoff between tools, repeated correction, retry loop, or recurring + workflow step. +- **Discussion prompt**: a question that asks the user to confirm, reject, or + refine an observed pattern before Recall treats it as a calibration target. +- **Calibration target**: a user-confirmed pattern that may justify changing a + workflow, skill, agent instruction, project process, or instruction file. +- **Proposal**: an optional, reviewable suggestion generated from a calibration + target. A proposal is not applied unless the user explicitly approves it. + +## Scope + +### In Scope + +- Add an official reflect extension workflow scoped by repository root or + repository identity. +- Reconstruct a timeline across relevant local sessions. +- Keep the default narrative conversation-first: user messages, assistant + responses, titles, summaries, timestamps, and source/project metadata. +- Hide or summarize raw tool calls, file reads/writes, command output, and + internal events by default. +- Surface observed workflow patterns as discussion prompts instead of final + judgments. +- Support optional workflow, skill, agent, project-process, or instruction-file + proposals after user confirmation. +- Keep proposal application explicit and reviewable. +- Provide machine-readable output for agents in addition to readable terminal + output. + +### Out of Scope + +- Treating reflection as a personality profile or scorecard. +- Automatically editing skills, prompts, project files, or instruction files + without user approval. +- Uploading reflection data or sharing it remotely by default. +- Replacing `recall search`, `recall session`, `recall export`, or the TUI. +- Making low-level tool/event logs the primary report format. + +## Product Model + +Reflect has five product layers. They describe the complete extension capability; an +implementation can deliver them incrementally. + +### 1. Timeline Reconstruction + +The user-facing input should stay simple. The common command should be Recall's +extension dispatch, based on the current repository or an explicit repository +root: + +```bash +recall reflect --project /path/to/repo +recall reflect --repo owner/repo +``` + +Direct extension invocation is acceptable for testing and development: + +```bash +recall-reflect --project /path/to/repo +recall-reflect --repo owner/repo +``` + +Internally, the extension consumes indexed session metadata and messages through +Recall's stable CLI protocol with `recall export --include metadata,messages`. +Future modes may opt into usage or event records only when they need that extra +context. These inputs should not make the user-facing command feel complex. + +The output of this layer is a chronological project narrative. Sessions are +evidence sources, but the top-level structure is time. + +Large project histories must not be handled by sending one full timeline to an +agent or model. Reflect should build a multi-resolution timeline: compact session +or time-window summaries first, then phase summaries, then a project-level +reflection. The user can drill back into the supporting sessions when needed, but +the default report should stay within a readable and reviewable size. + +### 2. Conversation Reflection + +The extension then looks for workflow patterns in the clean conversation timeline: + +- work continuing across sessions or tools; +- agent output becoming the next prompt or direction; +- repeated corrections or redirections; +- repeated planning, debugging, review, or verification loops; +- manual multi-step workflows that recur across sessions; +- places where the user and agent appear to disagree about scope or timing. + +These patterns should be phrased as observations and questions, not verdicts. +For example: + +```text +This timeline shows several points where implementation expanded after the user +had narrowed the scope. Is that a real workflow problem, or are these unrelated +moments? +``` + +### 3. User-Guided Calibration + +The extension should not convert every observation into a rule. The user confirms which +patterns matter. + +The calibration step turns an observed pattern into a target only after user +input: + +```text +Observed pattern: scope expanded during implementation in several timeline +moments. + +What should Recall do with this? +1. Ignore it. +2. Keep it as a note for future reflection. +3. Draft a workflow change. +4. Draft an agent/skill/instruction change. +``` + +This keeps Reflect discussion-first. The extension helps surface patterns; the user +decides whether they are real, useful, or actionable. + +### 4. Proposal Generation + +After confirmation, the extension can generate reviewable proposals. Proposal types may +include: + +- **Workflow proposal**: a new repeatable process or checkpoint. +- **Skill proposal**: a draft skill or a suggested change to an existing skill. +- **Agent behavior proposal**: a rule or behavior for an agent to follow in a + specific situation. +- **Instruction-file proposal**: a suggested change for files such as + `AGENTS.md` or `CLAUDE.md`. +- **Project-process proposal**: a project-level practice such as a checklist, + handoff note, test gate, or review habit. + +Each proposal should explain: + +- what would change; +- why this pattern led to the proposal; +- which timeline moments are relevant; +- what the user needs to approve before anything is written. + +### 5. Apply, Track, And Re-Reflect + +The complete loop does not end at proposal generation. If a user accepts a +proposal, the extension should be able to track that calibration and revisit it later. + +Future reflection can then answer: + +- What workflow or instruction change was accepted? +- Which sessions happened after the change? +- Did the same pattern disappear, continue, or change shape? +- Did the accepted change create a new kind of friction? + +This turns Reflect from a one-time summary into a long-term local feedback loop +for AI-assisted development. + +## Command Design + +### `recall reflect` + +Reflect on the current or selected project timeline through Recall's extension dispatch. + +```bash +recall reflect +recall reflect --project /path/to/repo +recall reflect --repo owner/repo +recall reflect --time 30d +recall reflect --format json +``` + +Options: + +- `--project `: project directory boundary, including child paths. +- `--repo `: repository identity such as `owner/repo` or a remote URL. +- `--time `: time window, default `all` or a + product-chosen recent default. +- `--source `: optional source filter for focused inspection. +- `--format `: default `text`. +- `--sync`: optionally run incremental sync before reflection. +- `--include-events`: include summarized low-level events as supporting context. + +When no project or repo is provided, Recall should prefer the current repository +root when it can be resolved. If no repository can be resolved, the command +should ask the calling agent or user to choose a project scope rather than +reflecting over all history by accident. + +### `recall reflect propose` + +Draft a proposal from a selected or confirmed calibration target. + +```bash +recall reflect propose --id +recall reflect propose --id --kind workflow +recall reflect propose --id --kind instruction --target AGENTS.md +``` + +This command prepares a proposal. It does not apply it. + +### `recall reflect apply` + +Apply a proposal after explicit user approval. + +```bash +recall reflect apply --proposal --dry-run +recall reflect apply --proposal +``` + +`--dry-run` should show the exact change. Applying should be a separate, +deliberate action. + +## Output Design + +Readable output should prefer short sections: + +```text +Recall reflect for : + +1. Timeline +2. Observed workflow patterns +3. Discussion prompts +4. Calibration targets confirmed by the user +5. Optional proposals +6. Follow-up checks from previous calibrations +``` + +The main timeline should not read like a tool log. It should read like a concise +history of human intent and agent response. + +For long histories, output should be layered rather than exhaustive: + +1. a short project-level summary; +2. a small number of timeline phases; +3. representative moments under each phase; +4. optional drill-down commands or ids for deeper inspection. + +JSON output should preserve enough structure for agents to continue the +conversation: + +```json +{ + "scope": { + "project": "/path/to/repo", + "repo": "owner/repo", + "time": "30d" + }, + "timeline": [ + { + "id": "moment-1", + "timestamp": 1781234567890, + "source": "codex", + "session_id": "...", + "kind": "conversation", + "summary": "User narrowed scope; agent proposed a broader implementation." + } + ], + "observed_patterns": [ + { + "id": "pattern-1", + "summary": "Scope expansion appeared after narrow requests.", + "timeline_moments": ["moment-1", "moment-4"], + "discussion_prompt": "Is this a workflow issue worth calibrating?" + } + ], + "proposals": [] +} +``` + +## Agent-Friendly Workflows + +### Reflect Before Planning + +```bash +recall reflect --project /path/to/repo --time 30d --format json +# Agent summarizes the timeline and asks which pattern, if any, the user wants to calibrate. +``` + +### Draft A Workflow Proposal + +```bash +recall reflect --project /path/to/repo +# User confirms a pattern. +recall reflect propose --id --kind workflow +``` + +### Preview An Instruction Change + +```bash +recall reflect propose --id --kind instruction --target AGENTS.md +recall reflect apply --proposal --dry-run +# User reviews the exact diff before applying. +``` + +## Privacy And Safety + +- Reflection data comes from local session history. +- Reports should summarize transcript content. They should not paste full + transcripts unless explicitly requested. +- Low-level tool calls, file paths, command output, and internal events should + be hidden or summarized by default. +- Reflect should not automatically modify source files, project configuration, + skills, prompts, or instruction files. +- Proposal application must require explicit user approval. +- Shared or exported reflection output may contain private intent and project + context; sharing belongs behind an explicit user action. + +## Backward Compatibility + +- Existing commands keep working: `recall search`, `recall session`, + `recall export`, `recall import`, `recall usage`, `recall share init`, and + the TUI remain unchanged. +- Existing session export schemas remain useful as internal data sources for + reflection. +- Reflect can be added without changing existing session-level workflows. + +## Relationship To Existing Skill Workflows + +Some agent skill systems already encode behavior learned from repeated AI coding +friction. The extension should treat those systems as possible calibration targets, +not as required dependencies. + +For example, a user might use reflection to decide that a recurring pattern +should become a new workflow, a skill change, or an instruction-file rule. The extension +should help prepare that proposal, but the user remains responsible for deciding +whether to adopt it. + +## Open Questions + +- Should `recall reflect` default to a recent time window or all indexed history? +- Should calibration targets be persisted in Recall's local database, written as + files, or only emitted in reports at first? +- Should proposal application support only instruction files first, or also + skills and project workflow documents? +- How should Reflect show optional low-level event context without turning the + main report into a tool log? +- Should future TUI support show Reflect as a new dashboard, a usage tab, or a + session-adjacent view? diff --git a/extensions/recall-reflect/Cargo.toml b/extensions/recall-reflect/Cargo.toml new file mode 100644 index 0000000..2ea65f8 --- /dev/null +++ b/extensions/recall-reflect/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "recall-reflect" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/extensions/recall-reflect/README.md b/extensions/recall-reflect/README.md new file mode 100644 index 0000000..4108052 --- /dev/null +++ b/extensions/recall-reflect/README.md @@ -0,0 +1,7 @@ +# recall-reflect + +Official Recall extension for timeline-first workflow reflection. + +It consumes Recall through CLI JSON/JSONL output and does not read Recall's SQLite database or Rust internals. + +When no `--project` or `--repo` is provided, `recall-reflect` scopes to the current git repository root. Outside a git worktree, pass `--project` or `--repo` explicitly. diff --git a/extensions/recall-reflect/src/lib.rs b/extensions/recall-reflect/src/lib.rs new file mode 100644 index 0000000..0e88b0a --- /dev/null +++ b/extensions/recall-reflect/src/lib.rs @@ -0,0 +1,6 @@ +pub mod manifest; +pub mod model; +pub mod patterns; +pub mod protocol; +pub mod render; +pub mod report; diff --git a/extensions/recall-reflect/src/main.rs b/extensions/recall-reflect/src/main.rs new file mode 100644 index 0000000..0c0da46 --- /dev/null +++ b/extensions/recall-reflect/src/main.rs @@ -0,0 +1,93 @@ +use std::process::Command; + +use anyhow::{Context, Result, bail}; +use clap::{Parser, ValueEnum}; + +use recall_reflect::manifest; +use recall_reflect::protocol::{RecallClient, ReflectArgs}; +use recall_reflect::render::render_text; +use recall_reflect::report::build_reflect_report; + +#[derive(Parser)] +#[command(name = "recall-reflect", version, about = "Reflect on Recall session history")] +struct Cli { + #[arg(long = "recall-extension-manifest", hide = true)] + recall_extension_manifest: bool, + #[arg(long, value_enum, default_value_t = OutputFormat::Text)] + format: OutputFormat, + #[arg(long)] + source: Option, + #[arg(long)] + time: Option, + #[arg(long)] + project: Option, + #[arg(long)] + repo: Option, + #[arg(long)] + sync: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +enum OutputFormat { + Text, + Json, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + if cli.recall_extension_manifest { + println!("{}", manifest::manifest_json()); + return Ok(()); + } + + let mut args = + ReflectArgs { source: cli.source, time: cli.time, project: cli.project, repo: cli.repo }; + apply_default_scope(&mut args)?; + + let client = RecallClient::from_env(); + if cli.sync { + client.sync(args.source.as_deref())?; + } + + let filters = args.filters(); + let sessions = client.export_sessions(&args)?; + let report = build_reflect_report(sessions, &filters); + + match cli.format { + OutputFormat::Text => print!("{}", render_text(&report)), + OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&report)?), + } + + Ok(()) +} + +fn apply_default_scope(args: &mut ReflectArgs) -> Result<()> { + if args.project.is_some() || args.repo.is_some() { + return Ok(()); + } + + args.project = Some(current_git_root()?); + Ok(()) +} + +fn current_git_root() -> Result { + let output = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output() + .context("failed to resolve the current git repository; pass --project or --repo")?; + + if !output.status.success() { + bail!("recall-reflect needs a scope outside a git worktree; pass --project or --repo"); + } + + let root = String::from_utf8(output.stdout) + .context("git repository root was not valid UTF-8; pass --project or --repo")? + .trim() + .to_string(); + + if root.is_empty() { + bail!("git did not return a repository root; pass --project or --repo"); + } + + Ok(root) +} diff --git a/extensions/recall-reflect/src/manifest.rs b/extensions/recall-reflect/src/manifest.rs new file mode 100644 index 0000000..6dbd150 --- /dev/null +++ b/extensions/recall-reflect/src/manifest.rs @@ -0,0 +1,24 @@ +use serde_json::{Value, json}; + +pub fn manifest_json() -> Value { + json!({ + "name": "reflect", + "version": env!("CARGO_PKG_VERSION"), + "protocol": 1, + "min_recall": "0.2.10" + }) +} + +#[cfg(test)] +mod tests { + use super::manifest_json; + + #[test] + fn manifest_matches_extension_contract() { + let manifest = manifest_json(); + assert_eq!(manifest["name"], "reflect"); + assert_eq!(manifest["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(manifest["protocol"], 1); + assert_eq!(manifest["min_recall"], "0.2.10"); + } +} diff --git a/extensions/recall-reflect/src/model.rs b/extensions/recall-reflect/src/model.rs new file mode 100644 index 0000000..b55914f --- /dev/null +++ b/extensions/recall-reflect/src/model.rs @@ -0,0 +1,98 @@ +use serde::Serialize; + +#[derive(Clone, Debug, Default)] +pub struct ReflectFilters { + pub sources: Option>, + pub time_range: String, + pub directory: Option, + pub repo: Option, +} + +#[derive(Clone, Debug)] +pub struct SourceSession { + pub id: String, + pub source: String, + pub title: String, + pub directory: Option, + pub started_at: Option, + pub updated_at: Option, + pub messages: Vec, +} + +#[derive(Clone, Debug)] +pub struct SourceMessage { + pub role: String, + pub content: String, + pub seq: u32, + pub timestamp: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ReflectScope { + pub project: Option, + pub repo: Option, + pub time_range: String, + pub sources: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ConversationChunk { + pub id: String, + pub session_id: String, + pub start_at: i64, + pub end_at: i64, + pub moment_ids: Vec, + pub summary: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ReflectSummary { + pub sessions: usize, + pub timeline_moments: usize, + pub phases: usize, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TimelinePhase { + pub id: String, + pub title: String, + pub start_at: i64, + pub end_at: i64, + pub summary: String, + pub moments: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TimelineMoment { + pub id: String, + pub timestamp: i64, + pub source: String, + pub session_id: String, + pub session_title: String, + pub role: String, + pub summary: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ObservedPattern { + pub id: String, + pub summary: String, + pub timeline_moments: Vec, + pub discussion_prompt: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ReflectProposalStub { + pub note: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ReflectReport { + pub scope: ReflectScope, + pub summary: ReflectSummary, + pub chunks: Vec, + pub phases: Vec, + pub observed_patterns: Vec, + pub proposals: Vec, + pub coverage_note: Option, +} diff --git a/extensions/recall-reflect/src/patterns.rs b/extensions/recall-reflect/src/patterns.rs new file mode 100644 index 0000000..e1234d7 --- /dev/null +++ b/extensions/recall-reflect/src/patterns.rs @@ -0,0 +1,27 @@ +use crate::model::{ObservedPattern, TimelineMoment}; + +pub(crate) fn detect_observed_patterns(moments: &[TimelineMoment]) -> Vec { + let scope_signals = ["scope", "don't expand", "do not expand", "keep it small", "不要扩大"]; + + let matched: Vec<&str> = moments + .iter() + .filter(|m| { + let lower = m.summary.to_lowercase(); + scope_signals.iter().any(|sig| lower.contains(&sig.to_lowercase())) + }) + .map(|m| m.id.as_str()) + .collect(); + + if matched.len() >= 2 { + vec![ObservedPattern { + id: "pattern-scope-boundary".to_string(), + summary: "Scope boundary reminders appeared in multiple timeline moments.".to_string(), + timeline_moments: matched.into_iter().map(String::from).collect(), + discussion_prompt: + "Is this a real workflow issue worth calibrating, or are these unrelated scope reminders?" + .to_string(), + }] + } else { + Vec::new() + } +} diff --git a/extensions/recall-reflect/src/protocol.rs b/extensions/recall-reflect/src/protocol.rs new file mode 100644 index 0000000..9dc428d --- /dev/null +++ b/extensions/recall-reflect/src/protocol.rs @@ -0,0 +1,176 @@ +use std::path::PathBuf; +use std::process::{Command, Output}; + +use anyhow::{Context, Result, anyhow}; +use serde::Deserialize; + +use crate::model::{ReflectFilters, SourceMessage, SourceSession}; + +#[derive(Clone, Debug, Default)] +pub struct ReflectArgs { + pub source: Option, + pub time: Option, + pub project: Option, + pub repo: Option, +} + +impl ReflectArgs { + pub fn filters(&self) -> ReflectFilters { + ReflectFilters { + sources: self.source.as_ref().map(|source| vec![source.clone()]), + time_range: self.time.clone().unwrap_or_else(|| "All".to_string()), + directory: self.project.clone(), + repo: self.repo.clone(), + } + } +} + +#[derive(Clone, Debug)] +pub struct RecallClient { + bin: PathBuf, +} + +impl RecallClient { + pub fn from_env() -> Self { + let bin = std::env::var_os("RECALL_BIN") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("recall")); + Self { bin } + } + + pub fn sync(&self, source: Option<&str>) -> Result<()> { + let mut args = vec!["sync".to_string()]; + if let Some(source) = source { + args.push("--source".to_string()); + args.push(source.to_string()); + } + + let output = self.run(&args)?; + ensure_success("sync", &args, output).map(|_| ()) + } + + pub fn export_sessions(&self, args: &ReflectArgs) -> Result> { + let mut command_args = vec![ + "export".to_string(), + "--limit".to_string(), + "0".to_string(), + "--include".to_string(), + "metadata,messages".to_string(), + ]; + push_optional_arg(&mut command_args, "--project", args.project.as_deref()); + push_optional_arg(&mut command_args, "--repo", args.repo.as_deref()); + push_optional_arg(&mut command_args, "--source", args.source.as_deref()); + push_optional_arg(&mut command_args, "--time", args.time.as_deref()); + + let output = self.run(&command_args)?; + let stdout = ensure_success("export", &command_args, output)?; + parse_jsonl(&stdout) + } + + fn run(&self, args: &[String]) -> Result { + Command::new(&self.bin).args(args).output().with_context(|| { + format!("failed to spawn Recall command `{}`", command_label(&self.bin, args)) + }) + } +} + +fn push_optional_arg(args: &mut Vec, flag: &str, value: Option<&str>) { + if let Some(value) = value { + args.push(flag.to_string()); + args.push(value.to_string()); + } +} + +fn ensure_success(action: &str, args: &[String], output: Output) -> Result { + if output.status.success() { + return String::from_utf8(output.stdout) + .with_context(|| format!("Recall {action} output was not valid UTF-8")); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let mut details = stderr.trim().to_string(); + if details.is_empty() { + details = stdout.trim().to_string(); + } + if details.is_empty() { + details = format!("exit status {}", output.status); + } + + Err(anyhow!( + "Recall command failed while running `{}`: {}", + command_label(PathBuf::from("recall").as_path(), args), + details + )) +} + +fn command_label(bin: &std::path::Path, args: &[String]) -> String { + let mut parts = Vec::with_capacity(args.len() + 1); + parts.push(bin.display().to_string()); + parts.extend(args.iter().cloned()); + parts.join(" ") +} + +fn parse_jsonl(stdout: &str) -> Result> { + stdout + .lines() + .enumerate() + .filter(|(_, line)| !line.trim().is_empty()) + .map(|(idx, line)| { + let record: ExportRecord = serde_json::from_str(line).with_context(|| { + format!("failed to parse Recall export JSONL record {}", idx + 1) + })?; + Ok(record.into_source_session()) + }) + .collect() +} + +#[derive(Debug, Deserialize)] +struct ExportRecord { + session: ExportSession, + #[serde(default)] + messages: Vec, +} + +#[derive(Debug, Deserialize)] +struct ExportSession { + id: String, + source: String, + title: String, + directory: Option, + started_at: Option, + updated_at: Option, +} + +#[derive(Debug, Deserialize)] +struct ExportMessage { + seq: u32, + role: String, + timestamp: Option, + content: String, +} + +impl ExportRecord { + fn into_source_session(self) -> SourceSession { + SourceSession { + id: self.session.id, + source: self.session.source, + title: self.session.title, + directory: self.session.directory, + started_at: self.session.started_at, + updated_at: self.session.updated_at, + messages: self.messages.into_iter().map(Into::into).collect(), + } + } +} + +impl From for SourceMessage { + fn from(message: ExportMessage) -> Self { + Self { + role: message.role, + content: message.content, + seq: message.seq, + timestamp: message.timestamp, + } + } +} diff --git a/extensions/recall-reflect/src/render.rs b/extensions/recall-reflect/src/render.rs new file mode 100644 index 0000000..f59508b --- /dev/null +++ b/extensions/recall-reflect/src/render.rs @@ -0,0 +1,132 @@ +use std::fmt::Write; + +use crate::model::ReflectReport; +use crate::report::REFLECT_CHUNK_MOMENT_LIMIT; + +pub fn render_text(report: &ReflectReport) -> String { + let mut out = String::new(); + + let _ = writeln!(out, "Recall reflect"); + let _ = writeln!(out); + + let _ = writeln!(out, "Scope"); + let _ = writeln!(out, " Project: {}", report.scope.project.as_deref().unwrap_or("-")); + let _ = writeln!(out, " Repo: {}", report.scope.repo.as_deref().unwrap_or("-")); + let _ = writeln!(out, " Time: {}", report.scope.time_range); + if !report.scope.sources.is_empty() { + let _ = writeln!(out, " Sources: {}", report.scope.sources.join(", ")); + } + let _ = writeln!(out); + + let _ = writeln!(out, "Summary"); + let _ = writeln!(out, " Sessions: {}", report.summary.sessions); + let _ = writeln!(out, " Moments: {}", report.summary.timeline_moments); + let _ = writeln!(out, " Phases: {}", report.summary.phases); + let _ = writeln!(out); + + if let Some(note) = &report.coverage_note { + let _ = writeln!(out, "Note: {note}"); + let _ = writeln!(out); + return out; + } + + for phase in &report.phases { + let _ = writeln!(out, "Timeline: {}", phase.title); + let _ = writeln!(out, " {}", phase.summary); + let _ = writeln!(out); + + for moment in phase.moments.iter().take(REFLECT_CHUNK_MOMENT_LIMIT) { + let time = format_message_time(moment.timestamp); + let _ = writeln!( + out, + " [{time}] [{role}] [{source}] {title}: {summary}", + role = moment.role, + source = moment.source, + title = moment.session_title, + summary = moment.summary, + ); + } + if phase.moments.len() > REFLECT_CHUNK_MOMENT_LIMIT { + let _ = writeln!( + out, + " ... and {} more moments", + phase.moments.len() - REFLECT_CHUNK_MOMENT_LIMIT + ); + } + let _ = writeln!(out); + } + + if !report.observed_patterns.is_empty() { + let _ = writeln!(out, "Discussion Prompts"); + for pattern in &report.observed_patterns { + let _ = writeln!(out, " - {}", pattern.discussion_prompt); + } + let _ = writeln!(out); + } + + out +} + +fn format_message_time(timestamp: i64) -> String { + timestamp.to_string() +} + +#[cfg(test)] +mod tests { + use crate::model::{ReflectFilters, SourceMessage, SourceSession}; + use crate::render::render_text; + use crate::report::build_reflect_report; + + fn fixture_session( + id: &str, + source: &str, + title: &str, + started_at: i64, + messages: Vec, + ) -> SourceSession { + SourceSession { + id: id.to_string(), + source: source.to_string(), + title: title.to_string(), + directory: Some("/tmp/reflect-repo".to_string()), + started_at: Some(started_at), + updated_at: None, + messages, + } + } + + fn fixture_message(role: &str, content: &str, seq: u32, timestamp: i64) -> SourceMessage { + SourceMessage { + role: role.to_string(), + content: content.to_string(), + seq, + timestamp: Some(timestamp), + } + } + + #[test] + fn reflect_text_output_is_timeline_first() { + let sessions = vec![fixture_session( + "s1", + "codex", + "Test session", + 1000, + vec![ + fixture_message("user", "hello world", 0, 1100), + fixture_message("assistant", "hi there", 1, 1200), + ], + )]; + + let report = build_reflect_report(sessions, &ReflectFilters::default()); + let text = render_text(&report); + + assert!(text.contains("Recall reflect"), "output must contain header"); + assert!(text.contains("Scope"), "output must contain Scope section"); + assert!(text.contains("Summary"), "output must contain Summary section"); + assert!(text.contains("Timeline"), "output must contain Timeline section"); + assert!(text.contains("Project conversation timeline"), "must include phase title"); + assert!(text.contains("hello world"), "must include user moment content"); + assert!(text.contains("hi there"), "must include assistant moment content"); + assert!(!text.contains("session_events"), "must not contain raw event names"); + } +} diff --git a/extensions/recall-reflect/src/report.rs b/extensions/recall-reflect/src/report.rs new file mode 100644 index 0000000..192c882 --- /dev/null +++ b/extensions/recall-reflect/src/report.rs @@ -0,0 +1,578 @@ +use std::collections::{BTreeMap, HashSet}; + +use crate::model::{ + ConversationChunk, ReflectFilters, ReflectReport, ReflectScope, ReflectSummary, SourceSession, + TimelineMoment, TimelinePhase, +}; +use crate::patterns::detect_observed_patterns; + +pub const REFLECT_CHUNK_MOMENT_LIMIT: usize = 10; + +pub fn build_reflect_report( + sessions: Vec, + filters: &ReflectFilters, +) -> ReflectReport { + let scope = ReflectScope { + project: filters.directory.clone(), + repo: filters.repo.clone(), + time_range: if filters.time_range.is_empty() { + "All".to_string() + } else { + filters.time_range.clone() + }, + sources: filters.sources.clone().unwrap_or_default(), + }; + + if sessions.is_empty() { + return ReflectReport { + scope, + summary: ReflectSummary { sessions: 0, timeline_moments: 0, phases: 0 }, + chunks: Vec::new(), + phases: Vec::new(), + observed_patterns: Vec::new(), + proposals: Vec::new(), + coverage_note: Some("No sessions matched the reflect scope.".to_string()), + }; + } + + let session_count = sessions.len(); + let mut moments = Vec::new(); + + for session in &sessions { + let _ = (&session.directory, &session.updated_at); + for msg in &session.messages { + let timestamp = msg.timestamp.unwrap_or_else(|| { + session + .started_at + .or(session.updated_at) + .unwrap_or(0) + .saturating_add(i64::from(msg.seq)) + }); + + let Some(cleaned) = sanitize_conversation_content(&msg.content) else { + continue; + }; + + moments.push(TimelineMoment { + id: format!("{}:{}", session.id, msg.seq), + timestamp, + source: session.source.clone(), + session_id: session.id.clone(), + session_title: session.title.clone(), + role: msg.role.clone(), + summary: compact_content(&cleaned, 180), + }); + } + } + + moments.sort_by(|a, b| { + a.timestamp + .cmp(&b.timestamp) + .then_with(|| a.session_id.cmp(&b.session_id)) + .then_with(|| a.id.cmp(&b.id)) + }); + + let timeline_moments_count = moments.len(); + + let mut sessions_by_id: BTreeMap> = BTreeMap::new(); + for moment in &moments { + sessions_by_id.entry(moment.session_id.clone()).or_default().push(moment); + } + + let mut chunks = Vec::new(); + for (session_id, session_moments) in &sessions_by_id { + for (chunk_idx, chunk_moments) in session_moments + .chunks(REFLECT_CHUNK_MOMENT_LIMIT) + .enumerate() + .filter(|(_, chunk)| !chunk.is_empty()) + { + let start_at = chunk_moments.first().map(|m| m.timestamp).unwrap_or(0); + let end_at = chunk_moments.last().map(|m| m.timestamp).unwrap_or(0); + let moment_ids = chunk_moments.iter().map(|m| m.id.clone()).collect(); + chunks.push(ConversationChunk { + id: format!("{}:chunk-{}", session_id, chunk_idx + 1), + session_id: session_id.clone(), + start_at, + end_at, + moment_ids, + summary: format!( + "{} conversation moments from {}.", + chunk_moments.len(), + session_id + ), + }); + } + } + chunks.sort_by(|a, b| a.start_at.cmp(&b.start_at).then_with(|| a.id.cmp(&b.id))); + + let observed_patterns = detect_observed_patterns(&moments); + let mut phases = Vec::new(); + + if !moments.is_empty() { + let start_at = moments.first().map(|m| m.timestamp).unwrap_or(0); + let end_at = moments.last().map(|m| m.timestamp).unwrap_or(0); + let session_ids: HashSet<&str> = moments.iter().map(|m| m.session_id.as_str()).collect(); + + phases.push(TimelinePhase { + id: "phase-1".to_string(), + title: "Project conversation timeline".to_string(), + start_at, + end_at, + summary: format!( + "{} conversation moments in {} chunks across {} sessions.", + timeline_moments_count, + chunks.len(), + session_ids.len() + ), + moments, + }); + } + + ReflectReport { + scope, + summary: ReflectSummary { + sessions: session_count, + timeline_moments: timeline_moments_count, + phases: phases.len(), + }, + chunks, + phases, + observed_patterns, + proposals: Vec::new(), + coverage_note: None, + } +} + +fn compact_content(content: &str, max_chars: usize) -> String { + let collapsed = content.split_whitespace().collect::>().join(" "); + if collapsed.len() <= max_chars { + collapsed + } else { + let mut truncated: String = collapsed.chars().take(max_chars).collect(); + truncated.push_str("..."); + truncated + } +} + +fn sanitize_conversation_content(content: &str) -> Option { + if is_low_level_transcript_log(content) { + return None; + } + + let tool_names = [ + "Bash", + "Read", + "Write", + "Edit", + "Grep", + "Glob", + "LS", + "TodoWrite", + "Task", + "WebFetch", + "WebSearch", + ]; + + let mut cut_pos = content.len(); + for name in &tool_names { + let marker = format!("[{name}]"); + if let Some(pos) = content.find(&marker) + && pos > 0 + && pos < cut_pos + { + cut_pos = pos; + } + } + + if cut_pos < content.len() { + let sanitized = content[..cut_pos].trim_end(); + if sanitized.is_empty() { + return None; + } + return Some(sanitized.to_string()); + } + + Some(content.to_string()) +} + +fn is_low_level_transcript_log(content: &str) -> bool { + let tool_prefixes = [ + "[Bash]", + "[Read]", + "[Write]", + "[Edit]", + "[Grep]", + "[Glob]", + "[LS]", + "[TodoWrite]", + "[Task]", + "[WebFetch]", + "[WebSearch]", + ]; + + if tool_prefixes.iter().any(|p| content.starts_with(p)) { + return true; + } + + let envelope_prefixes = + ["", "", "", ""]; + + if envelope_prefixes.iter().any(|p| content.starts_with(p)) { + return true; + } + + if content.starts_with("The file ") && content.contains(" has been updated successfully.") { + return true; + } + + if content.starts_with("File created successfully at:") { + return true; + } + + if content.starts_with("Tool execution aborted") { + return true; + } + + if content.starts_with("(Bash completed with no output)") { + return true; + } + + if content.starts_with("The user doesn't want to proceed with this tool use") { + return true; + } + + if content.starts_with("[Request interrupted by user for tool use]") { + return true; + } + + is_line_numbered_file_dump(content) +} + +fn is_line_numbered_file_dump(content: &str) -> bool { + let trimmed = content.trim_start(); + let bytes = trimmed.as_bytes(); + if bytes.is_empty() || !bytes[0].is_ascii_digit() { + return false; + } + for i in 1..bytes.len().min(6) { + let b = bytes[i]; + if b == b'#' || (b.is_ascii_whitespace() && i + 1 < bytes.len() && bytes[i + 1] == b'#') { + return true; + } + if !b.is_ascii_digit() && !b.is_ascii_whitespace() { + break; + } + } + false +} + +#[cfg(test)] +mod tests { + use crate::model::{ReflectFilters, SourceMessage, SourceSession}; + use crate::render::render_text; + use crate::report::build_reflect_report; + + fn fixture_session( + id: &str, + source: &str, + title: &str, + started_at: i64, + messages: Vec, + ) -> SourceSession { + SourceSession { + id: id.to_string(), + source: source.to_string(), + title: title.to_string(), + directory: Some("/tmp/reflect-repo".to_string()), + started_at: Some(started_at), + updated_at: None, + messages, + } + } + + fn fixture_message(role: &str, content: &str, seq: u32, timestamp: i64) -> SourceMessage { + SourceMessage { + role: role.to_string(), + content: content.to_string(), + seq, + timestamp: Some(timestamp), + } + } + + #[test] + fn reflect_empty_scope_returns_coverage_note() { + let report = build_reflect_report(Vec::new(), &ReflectFilters::default()); + + assert_eq!(report.coverage_note.as_deref(), Some("No sessions matched the reflect scope.")); + assert_eq!(report.summary.sessions, 0); + assert_eq!(report.summary.timeline_moments, 0); + assert_eq!(report.summary.phases, 0); + assert!(report.phases.is_empty()); + assert!(report.observed_patterns.is_empty()); + assert!(report.proposals.is_empty()); + assert!(report.chunks.is_empty()); + } + + #[test] + fn reflect_builds_timeline_across_sessions() { + let sessions = vec![ + fixture_session( + "s1", + "codex", + "Codex session", + 1000, + vec![ + fixture_message("user", "hello", 0, 1000), + fixture_message("assistant", "hi there", 1, 1100), + ], + ), + fixture_session( + "s2", + "opencode", + "OpenCode session", + 500, + vec![ + fixture_message("user", "how fix parser", 0, 600), + fixture_message("assistant", "check imports", 1, 700), + ], + ), + ]; + + let report = build_reflect_report(sessions, &ReflectFilters::default()); + + assert_eq!(report.summary.sessions, 2); + assert_eq!(report.summary.timeline_moments, 4); + assert_eq!(report.summary.phases, 1); + assert_eq!(report.phases.len(), 1, "should have one timeline phase"); + + let phase = &report.phases[0]; + assert_eq!(phase.id, "phase-1"); + assert_eq!(phase.title, "Project conversation timeline"); + assert_eq!(phase.moments.len(), 4); + assert_eq!(phase.moments[0].id, "s2:0"); + assert_eq!(phase.moments[0].timestamp, 600); + assert_eq!(phase.moments[0].role, "user"); + assert_eq!(phase.moments[0].session_id, "s2"); + assert_eq!(phase.moments[0].source, "opencode"); + assert_eq!(phase.moments[0].session_title, "OpenCode session"); + assert_eq!(phase.moments[1].id, "s2:1"); + assert_eq!(phase.moments[1].timestamp, 700); + assert_eq!(phase.moments[1].role, "assistant"); + assert_eq!(phase.moments[2].id, "s1:0"); + assert_eq!(phase.moments[2].timestamp, 1000); + assert_eq!(phase.moments[3].id, "s1:1"); + assert_eq!(phase.moments[3].timestamp, 1100); + + for moment in &phase.moments { + assert!(!moment.summary.is_empty(), "moment {} should have summary", moment.id); + } + assert_eq!(phase.start_at, 600); + assert_eq!(phase.end_at, 1100); + assert!(phase.summary.contains("conversation moments")); + assert!(phase.summary.contains("2 sessions")); + assert!(report.observed_patterns.is_empty()); + assert!(report.proposals.is_empty()); + assert!(report.coverage_note.is_none()); + } + + #[test] + fn reflect_chunks_long_sessions_before_project_summary() { + let messages = (0..25) + .map(|i| { + let role = if i % 2 == 0 { "user" } else { "assistant" }; + fixture_message(role, &format!("message {i}"), i, 1000 + i64::from(i) * 100) + }) + .collect(); + let sessions = vec![fixture_session("s1", "codex", "Long session", 1000, messages)]; + + let report = build_reflect_report(sessions, &ReflectFilters::default()); + + assert!(report.chunks.len() > 1, "25 moments should produce multiple chunks"); + assert_eq!(report.summary.timeline_moments, 25); + assert!(report.phases[0].summary.contains("chunks")); + } + + #[test] + fn reflect_scope_pattern_is_discussion_prompt_only() { + let sessions = vec![ + fixture_session( + "s1", + "codex", + "Scope session one", + 1000, + vec![ + fixture_message("user", "Keep it small; do not expand scope.", 0, 1100), + fixture_message("assistant", "Understood, staying focused.", 1, 1200), + ], + ), + fixture_session( + "s2", + "codex", + "Scope session two", + 2000, + vec![ + fixture_message("user", "Again, don't expand scope this time.", 0, 2100), + fixture_message("assistant", "Got it.", 1, 2200), + ], + ), + ]; + + let report = build_reflect_report(sessions, &ReflectFilters::default()); + + assert_eq!(report.observed_patterns.len(), 1); + let pattern = &report.observed_patterns[0]; + assert_eq!(pattern.id, "pattern-scope-boundary"); + assert!(pattern.summary.to_lowercase().contains("scope")); + assert_eq!(pattern.timeline_moments.len(), 2); + assert!(pattern.discussion_prompt.to_lowercase().contains("workflow issue")); + assert!(report.proposals.is_empty(), "proposals must remain empty"); + } + + #[test] + fn reflect_scope_pattern_requires_repeated_signal() { + let sessions = vec![fixture_session( + "s1", + "codex", + "Single scope session", + 1000, + vec![ + fixture_message("user", "Please do not expand scope.", 0, 1100), + fixture_message("assistant", "Got it.", 1, 1200), + ], + )]; + + let report = build_reflect_report(sessions, &ReflectFilters::default()); + + assert!(report.observed_patterns.is_empty()); + } + + #[test] + fn reflect_excludes_low_level_transcript_logs_by_default() { + let sessions = vec![fixture_session( + "s1", + "opencode", + "Mixed session", + 1000, + vec![ + fixture_message("user", "Please review the timeline design", 0, 1100), + fixture_message("assistant", "I will review the design at a high level", 1, 1200), + fixture_message("assistant", "[Bash] {\"command\":\"git status\"}", 2, 1300), + fixture_message( + "assistant", + "[Read] {\"file_path\":\"docs/extensions/reflect.md\"}", + 3, + 1400, + ), + fixture_message("assistant", "[Write] {\"file_path\":\"x\"}", 4, 1500), + fixture_message( + "assistant", + "ui-ux-pro-max", + 5, + 1600, + ), + fixture_message( + "assistant", + "Copied", + 6, + 1700, + ), + fixture_message( + "assistant", + "The file /tmp/example.md has been updated successfully.", + 7, + 1800, + ), + fixture_message("assistant", "(Bash completed with no output)", 8, 1900), + fixture_message( + "assistant", + "I ran a quick bash script to verify the read paths and write output.", + 9, + 2000, + ), + ], + )]; + + let report = build_reflect_report(sessions, &ReflectFilters::default()); + let surviving_summaries: Vec<&str> = + report.phases[0].moments.iter().map(|m| m.summary.as_str()).collect(); + + assert_eq!(surviving_summaries.len(), 3); + assert!(surviving_summaries.iter().any(|s| s.contains("review the timeline"))); + assert!(surviving_summaries.iter().any(|s| s.contains("review the design"))); + assert!(surviving_summaries.iter().any(|s| s.contains("bash script"))); + for summary in &surviving_summaries { + assert!(!summary.starts_with("[Bash]"), "tool log prefix leaked: {summary}"); + assert!(!summary.starts_with("[Read]"), "tool log prefix leaked: {summary}"); + assert!(!summary.starts_with("[Write]"), "tool log prefix leaked: {summary}"); + assert!( + !summary.starts_with(""), + "command envelope leaked: {summary}" + ); + assert!( + !summary.starts_with(""), + "stdout envelope leaked: {summary}" + ); + assert!(!summary.starts_with("(Bash completed"), "bash completion leaked: {summary}"); + } + let text = render_text(&report); + assert!(!text.contains("[Bash]")); + assert!(!text.contains("[Read]")); + assert!(!text.contains("")); + assert!(!text.contains("")); + } + + #[test] + fn reflect_sanitizes_inline_tool_artifacts() { + let sessions = vec![fixture_session( + "s1", + "opencode", + "Inline artifacts session", + 1000, + vec![ + fixture_message( + "assistant", + "I'll review the docs. [Read] {\"file_path\":\"docs/extensions/reflect.md\"}", + 0, + 1100, + ), + fixture_message( + "user", + "The user doesn't want to proceed with this tool use...", + 1, + 1200, + ), + fixture_message("user", "[Request interrupted by user for tool use]", 2, 1300), + fixture_message("user", "1 # Heading 2 3 content from file", 3, 1400), + fixture_message("assistant", "I ran a bash script to read the file", 4, 1500), + fixture_message("user", "Please check the output", 5, 1600), + ], + )]; + + let report = build_reflect_report(sessions, &ReflectFilters::default()); + let surviving_summaries: Vec<&str> = + report.phases[0].moments.iter().map(|m| m.summary.as_str()).collect(); + + assert_eq!(surviving_summaries.len(), 3); + let sanitized = surviving_summaries + .iter() + .find(|s| s.contains("review the docs")) + .expect("sanitized inline-tool message must survive"); + assert!(!sanitized.contains("[Read]")); + assert!(!sanitized.contains("file_path")); + assert!(!sanitized.contains("{\"")); + for summary in &surviving_summaries { + assert!(!summary.contains("doesn't want to proceed")); + assert!(!summary.contains("Request interrupted")); + assert!(!summary.starts_with("1 #")); + } + assert!(surviving_summaries.iter().any(|s| s.contains("bash script"))); + assert!(surviving_summaries.iter().any(|s| s.contains("check the output"))); + + let text = render_text(&report); + assert!(!text.contains("[Read]")); + assert!(!text.contains("file_path")); + assert!(!text.contains("doesn't want to proceed")); + assert!(!text.contains("Request interrupted")); + assert!(text.contains("review the docs")); + } +} diff --git a/extensions/recall-reflect/tests/reflect_cli.rs b/extensions/recall-reflect/tests/reflect_cli.rs new file mode 100644 index 0000000..dede995 --- /dev/null +++ b/extensions/recall-reflect/tests/reflect_cli.rs @@ -0,0 +1,265 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +const JSONL_FIXTURE: &str = r#"{"schema_version":4,"record_type":"session","session":{"id":"s1","source":"codex","source_id":"source-s1","title":"Codex session","directory":"/tmp/repo","repo_remote":"git@example.com:owner/repo.git","repo_slug":"owner/repo","repo_name":"repo","started_at":1000,"updated_at":1200,"message_count":2,"entrypoint":null,"custom_title":null,"summary":null,"duration_minutes":null,"source_file_path":null},"messages":[{"seq":0,"role":"user","timestamp":1000,"content":"please keep scope small"},{"seq":1,"role":"assistant","timestamp":1100,"content":"I will keep it focused"}],"usage_events":[],"events":[]} +"#; + +#[test] +fn reflect_cli_reads_export_jsonl_from_recall_bin() { + let fake = FakeRecall::new(JSONL_FIXTURE, 0, ""); + + let output = Command::new(env!("CARGO_BIN_EXE_recall-reflect")) + .env("RECALL_BIN", fake.script_path()) + .args([ + "--project", + "/tmp/repo", + "--repo", + "owner/repo", + "--source", + "codex", + "--time", + "week", + "--format", + "json", + ]) + .output() + .unwrap(); + + assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + assert!(output.stderr.is_empty(), "stderr should be empty on success"); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["summary"]["sessions"], 1); + assert_eq!(json["summary"]["timeline_moments"], 2); + assert_eq!(json["scope"]["project"], "/tmp/repo"); + assert_eq!(json["scope"]["repo"], "owner/repo"); + assert_eq!(json["scope"]["time_range"], "week"); + assert_eq!(json["scope"]["sources"][0], "codex"); + + let calls = fake.calls(); + assert_eq!( + calls, + [ + "export --limit 0 --include metadata,messages --project /tmp/repo --repo owner/repo --source codex --time week" + ] + ); +} + +#[test] +fn reflect_cli_syncs_selected_source_before_export() { + let fake = FakeRecall::new(JSONL_FIXTURE, 0, ""); + let repo = TempDir::new("recall-reflect-repo"); + init_git_repo(repo.path()); + + let output = Command::new(env!("CARGO_BIN_EXE_recall-reflect")) + .env("RECALL_BIN", fake.script_path()) + .current_dir(repo.path()) + .args(["--sync", "--source", "opencode"]) + .output() + .unwrap(); + + assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("Recall reflect")); + assert!(stdout.contains("Sessions: 1")); + + let calls = fake.calls(); + assert_eq!( + calls, + [ + "sync --source opencode", + &format!( + "export --limit 0 --include metadata,messages --project {} --source opencode", + repo.path().display() + ) + ] + ); +} + +#[test] +fn reflect_cli_defaults_unscoped_reflection_to_current_git_root() { + let fake = FakeRecall::new(JSONL_FIXTURE, 0, ""); + let repo = TempDir::new("recall-reflect-repo"); + init_git_repo(repo.path()); + let nested = repo.path().join("nested").join("child"); + fs::create_dir_all(&nested).unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_recall-reflect")) + .env("RECALL_BIN", fake.script_path()) + .current_dir(&nested) + .arg("--format") + .arg("json") + .output() + .unwrap(); + + assert!(output.status.success(), "stderr: {}", String::from_utf8_lossy(&output.stderr)); + assert!(output.stderr.is_empty(), "stderr should be empty on success"); + + let json: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(json["scope"]["project"], repo.path().display().to_string()); + + let calls = fake.calls(); + assert_eq!( + calls, + [format!( + "export --limit 0 --include metadata,messages --project {}", + repo.path().display() + )] + ); +} + +#[test] +fn reflect_cli_requires_explicit_scope_outside_git_worktree() { + let fake = FakeRecall::new(JSONL_FIXTURE, 0, ""); + let non_git = TempDir::new("recall-reflect-non-git"); + + let output = Command::new(env!("CARGO_BIN_EXE_recall-reflect")) + .env("RECALL_BIN", fake.script_path()) + .current_dir(non_git.path()) + .output() + .unwrap(); + + assert!(!output.status.success(), "command should fail outside git without scope"); + assert!(output.stdout.is_empty(), "stdout must be empty on scope errors"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--project") || stderr.contains("--repo"), "stderr: {stderr}"); + + assert!(fake.calls().is_empty(), "export must not run without an explicit or inferred scope"); +} + +#[test] +fn reflect_cli_reports_recall_command_failures_on_stderr() { + let fake = FakeRecall::new("", 23, "boom"); + + let output = Command::new(env!("CARGO_BIN_EXE_recall-reflect")) + .env("RECALL_BIN", fake.script_path()) + .arg("--project") + .arg("/tmp/repo") + .arg("--format") + .arg("json") + .output() + .unwrap(); + + assert!(!output.status.success(), "command should fail"); + assert!(output.stdout.is_empty(), "stdout must be data-only and empty on command errors"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Recall command failed"), "stderr: {stderr}"); + assert!(stderr.contains("boom"), "stderr: {stderr}"); + + let calls = fake.calls(); + assert_eq!(calls, ["export --limit 0 --include metadata,messages --project /tmp/repo"]); +} + +struct TempDir { + path: PathBuf, +} + +impl TempDir { + fn new(prefix: &str) -> Self { + let path = unique_temp_dir(prefix); + fs::create_dir_all(&path).unwrap(); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +struct FakeRecall { + dir: PathBuf, + script: PathBuf, + calls: PathBuf, +} + +impl FakeRecall { + fn new(export_stdout: &str, export_exit_code: i32, export_stderr: &str) -> Self { + let dir = unique_temp_dir("recall-reflect-test"); + fs::create_dir_all(&dir).unwrap(); + let script = dir.join("recall-fake.sh"); + let calls = dir.join("calls.txt"); + let stdout_path = dir.join("export.jsonl"); + let stderr_path = dir.join("export.stderr"); + fs::write(&stdout_path, export_stdout).unwrap(); + fs::write(&stderr_path, export_stderr).unwrap(); + fs::write(&calls, "").unwrap(); + + let script_body = format!( + r#"#!/bin/sh +printf '%s\n' "$*" >> "{calls}" +if [ "$1" = "sync" ]; then + exit 0 +fi +if [ "$1" = "export" ]; then + cat "{stderr_path}" >&2 + cat "{stdout_path}" + exit {export_exit_code} +fi +echo "unexpected command: $*" >&2 +exit 99 +"#, + calls = calls.display(), + stdout_path = stdout_path.display(), + stderr_path = stderr_path.display(), + export_exit_code = export_exit_code, + ); + fs::write(&script, script_body).unwrap(); + make_executable(&script); + + Self { dir, script, calls } + } + + fn script_path(&self) -> &Path { + &self.script + } + + fn calls(&self) -> Vec { + fs::read_to_string(&self.calls).unwrap().lines().map(str::to_string).collect() + } +} + +impl Drop for FakeRecall { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.dir); + } +} + +fn unique_temp_dir(prefix: &str) -> PathBuf { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); + std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id())) +} + +fn init_git_repo(path: &Path) { + let output = Command::new("git") + .env("GIT_MASTER", "1") + .arg("init") + .arg("--quiet") + .current_dir(path) + .output() + .unwrap(); + assert!( + output.status.success(), + "git init stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(unix)] +fn make_executable(path: &Path) { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = fs::metadata(path).unwrap().permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).unwrap(); +} + +#[cfg(not(unix))] +fn make_executable(_path: &Path) {} diff --git a/skills/recall/SKILL.md b/skills/recall/SKILL.md index 61a9eb0..0bc901f 100644 --- a/skills/recall/SKILL.md +++ b/skills/recall/SKILL.md @@ -26,6 +26,7 @@ Pick the workflow from user intent. Do not run the full project-review scoping f | Resume or open | "继续这个会话", "resume this chat" | Session Resume/Open | | Find one session | "找上次讨论 migration 的会话", "当前项目最新 grok 会话" | Latest/Find Session Lookup | | Review project history | "用 recall 审查这个项目", "历史风险" | Scoping Protocol + Analysis Modes | +| Reflect on AI coding workflow | "reflect on this project", "review my AI coding workflow", "timeline reflection", "workflow friction", "calibration discussion" | Route to the `reflect` skill | Treat "share" and "update/refresh share link" as the same action: sync the latest transcript, publish to Pages, return the live URL. @@ -224,6 +225,10 @@ Recall review of : Keep transcript content summarized. Quote only short excerpts when they are necessary evidence. +## Reflect Routing + +When the user wants project-level reflection on their AI coding process, workflow friction, handoffs, repeated corrections, or possible future calibration, route to the installed `reflect` skill. Keep this `recall` skill focused on project memory lookup, sharing, export, and review workflows. + ## Commands For Tool Calls Use these Recall commands in agent tool calls: diff --git a/skills/reflect/SKILL.md b/skills/reflect/SKILL.md new file mode 100644 index 0000000..8c62d29 --- /dev/null +++ b/skills/reflect/SKILL.md @@ -0,0 +1,110 @@ +--- +name: reflect +description: Use Recall Reflect to review AI coding workflow history as a conversation-first timeline and discuss observed patterns before changing behavior. Trigger when the user asks to reflect on a project, review AI coding workflow, inspect workflow friction, discuss repeated corrections, review handoffs, or explore possible calibration. +--- + +# Reflect + +## Overview + +Reflect is the official `recall-reflect` Recall extension. It is a discussion-first workflow for reviewing AI coding sessions through Recall's stable CLI JSON and JSONL protocol. The extension reconstructs a conversation-first timeline across sessions and surfaces observed patterns as questions for the user, not as automatic conclusions or edits. + +Use this skill when the user wants project-level reflection on AI coding process, workflow friction, handoffs, repeated corrections, scope drift, or possible future calibration. + +Reflect output is evidence for discussion, not permission to change skills, prompts, configs, instruction files, or project files. + +## Core Rule + +Do not treat an observed pattern as a calibration target, workflow change, or proposal unless the user explicitly confirms the interpretation. Ask first, then act only on confirmed direction. + +## Scope Defaults + +Scope to the current repo unless the user asks otherwise. Resolve the project directory with: + +```bash +git rev-parse --show-toplevel +``` + +Run Reflect with structured output. Prefer user-facing dispatch when the extension is installed and managed by Recall: + +```bash +recall reflect --project /absolute/project/path --format json +``` + +Direct extension invocation is also acceptable when testing or calling the extension binary explicitly: + +```bash +recall-reflect --project /absolute/project/path --format json +``` + +Add `--time 30d` or `--source ` when the user narrows the review. Use `--sync` when recent sessions may not be indexed yet. + +## Workflow + +1. Resolve the repo scope. + - Use the repository root for current-repo reflection. + - Use the user-provided path when they specify one. + - Explain the scope briefly if there may be multiple worktrees or project directories. + +2. Run Reflect. + - Prefer `recall reflect ...` when Recall can dispatch to the installed extension. + - Use `recall-reflect ...` for direct testing or explicit extension invocation. + - Prefer JSON for structured inspection. + - Use text output only when the user wants a human-readable report directly. + +3. Read the timeline as conversation history. + - Focus on user intent, agent response, corrections, decisions, and outcomes. + - Do not turn the timeline into a tool-call log. + - Do not quote large transcript blocks. + +4. Present observed patterns as discussion prompts. + - Use the prompt text from `observed_patterns[].discussion_prompt` when present. + - If there are no observed patterns, summarize the timeline and say there is not enough repeated evidence yet to discuss a pattern. + - Do not add confidence scores, personality labels, or identity judgments. + +5. Discuss before proposing changes. + - Ask which pattern, if any, the user wants to inspect further. + - Ask whether the pattern is real, intentional, or a false positive. + - Only after user confirmation may you suggest a calibration target, workflow rule, or follow-up implementation plan. + +## Output Shape + +Prefer concise discussion framing: + +```text +Reflect review of : + +1. Timeline in plain language +2. Observed pattern prompts +3. One question for the user +``` + +When a pattern appears, ask about it directly: + +```text +The timeline shows repeated scope-boundary corrections. Is this a real workflow issue worth calibrating, or are these unrelated reminders? +``` + +## Commands + +```bash +recall reflect --project /absolute/project/path --format json +recall reflect --project /absolute/project/path --time 30d --format json +recall reflect --project /absolute/project/path --source codex --format json +recall reflect --project /absolute/project/path --sync --format json +recall reflect --project /absolute/project/path --format text +recall-reflect --project /absolute/project/path --format json +``` + +Supported time filters are `today`, `7d` or `week`, and `30d` or `month`. Unknown time values fall back to all history. + +Supported source ids include `claude-code`, `opencode`, `codex`, `pi`, `antigravity-cli`, `gemini-cli`, `grok`, `kiro-cli`, `copilot-cli`, `cursor`, and `cline`. + +## Guardrails + +- Do not present Reflect output as a diagnosis of the user. +- Do not infer private intent beyond what the timeline shows. +- Do not turn a pattern into a rule without explicit user confirmation. +- Do not edit files from Reflect output alone. +- Verify any technical claim against the current repository before acting on it. +- Keep transcript content summarized. Quote only short excerpts when necessary. diff --git a/skills/reflect/agents/openai.yaml b/skills/reflect/agents/openai.yaml new file mode 100644 index 0000000..895a8f1 --- /dev/null +++ b/skills/reflect/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Reflect" + short_description: "Discuss AI coding workflow patterns" + default_prompt: "Use $reflect to review this project's AI coding workflow history and discuss observed patterns before proposing changes."