Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
656 changes: 370 additions & 286 deletions docs/extensions/reflect.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion extensions/recall-reflect/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@ 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.
When no `--project`, `--repo`, or `--personal` scope is provided,
`recall-reflect` scopes to the current git repository root inside a git
worktree. Outside a git worktree, it defaults to personal reflection over the
recent `30d` window. Pass `--personal` to force personal reflection inside a
repository.
43 changes: 35 additions & 8 deletions extensions/recall-reflect/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use anyhow::{Context, Result, bail};
use clap::{Parser, ValueEnum};

use recall_reflect::manifest;
use recall_reflect::model::ReflectScopeKind;
use recall_reflect::protocol::{RecallClient, ReflectArgs};
use recall_reflect::render::render_text;
use recall_reflect::report::build_reflect_report;
Expand All @@ -24,6 +25,8 @@ struct Cli {
#[arg(long)]
repo: Option<String>,
#[arg(long)]
personal: bool,
#[arg(long)]
sync: bool,
}

Expand All @@ -40,8 +43,17 @@ fn main() -> Result<()> {
return Ok(());
}

let mut args =
ReflectArgs { source: cli.source, time: cli.time, project: cli.project, repo: cli.repo };
let mut args = ReflectArgs {
scope_kind: if cli.personal {
ReflectScopeKind::Personal
} else {
ReflectScopeKind::Project
},
source: cli.source,
time: cli.time,
project: cli.project,
repo: cli.repo,
};
apply_default_scope(&mut args)?;

let client = RecallClient::from_env();
Expand All @@ -62,22 +74,37 @@ fn main() -> Result<()> {
}

fn apply_default_scope(args: &mut ReflectArgs) -> Result<()> {
if args.scope_kind == ReflectScopeKind::Personal {
if args.project.is_some() || args.repo.is_some() {
bail!("--personal cannot be combined with --project or --repo");
}
if args.time.is_none() {
args.time = Some("30d".to_string());
}
return Ok(());
}

if args.project.is_some() || args.repo.is_some() {
return Ok(());
}

args.project = Some(current_git_root()?);
if let Some(root) = current_git_root()? {
args.project = Some(root);
} else {
args.scope_kind = ReflectScopeKind::Personal;
args.time = Some(args.time.clone().unwrap_or_else(|| "30d".to_string()));
}
Ok(())
}

fn current_git_root() -> Result<String> {
fn current_git_root() -> Result<Option<String>> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.context("failed to resolve the current git repository; pass --project or --repo")?;
.context("failed to resolve the current git repository")?;

if !output.status.success() {
bail!("recall-reflect needs a scope outside a git worktree; pass --project or --repo");
return Ok(None);
}

let root = String::from_utf8(output.stdout)
Expand All @@ -86,8 +113,8 @@ fn current_git_root() -> Result<String> {
.to_string();

if root.is_empty() {
bail!("git did not return a repository root; pass --project or --repo");
bail!("git did not return a repository root");
}

Ok(root)
Ok(Some(root))
}
32 changes: 31 additions & 1 deletion extensions/recall-reflect/src/model.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,42 @@
use serde::Serialize;

#[derive(Clone, Debug, Default)]
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReflectScopeKind {
Project,
Personal,
}

impl ReflectScopeKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Project => "project",
Self::Personal => "personal",
}
}
}

#[derive(Clone, Debug)]
pub struct ReflectFilters {
pub scope_kind: ReflectScopeKind,
pub sources: Option<Vec<String>>,
pub time_range: String,
pub directory: Option<String>,
pub repo: Option<String>,
}

impl Default for ReflectFilters {
fn default() -> Self {
Self {
scope_kind: ReflectScopeKind::Project,
sources: None,
time_range: String::new(),
directory: None,
repo: None,
}
}
}

#[derive(Clone, Debug)]
pub struct SourceSession {
pub id: String,
Expand All @@ -29,6 +58,7 @@ pub struct SourceMessage {

#[derive(Debug, Clone, Serialize)]
pub struct ReflectScope {
pub kind: ReflectScopeKind,
pub project: Option<String>,
pub repo: Option<String>,
pub time_range: String,
Expand Down
18 changes: 16 additions & 2 deletions extensions/recall-reflect/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,33 @@ use std::process::{Command, Output};
use anyhow::{Context, Result, anyhow};
use serde::Deserialize;

use crate::model::{ReflectFilters, SourceMessage, SourceSession};
use crate::model::{ReflectFilters, ReflectScopeKind, SourceMessage, SourceSession};

#[derive(Clone, Debug, Default)]
#[derive(Clone, Debug)]
pub struct ReflectArgs {
pub scope_kind: ReflectScopeKind,
pub source: Option<String>,
pub time: Option<String>,
pub project: Option<String>,
pub repo: Option<String>,
}

impl Default for ReflectArgs {
fn default() -> Self {
Self {
scope_kind: ReflectScopeKind::Project,
source: None,
time: None,
project: None,
repo: None,
}
}
}

impl ReflectArgs {
pub fn filters(&self) -> ReflectFilters {
ReflectFilters {
scope_kind: self.scope_kind,
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(),
Expand Down
1 change: 1 addition & 0 deletions extensions/recall-reflect/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ pub fn render_text(report: &ReflectReport) -> String {
let _ = writeln!(out);

let _ = writeln!(out, "Scope");
let _ = writeln!(out, " Kind: {}", report.scope.kind.as_str());
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);
Expand Down
55 changes: 52 additions & 3 deletions extensions/recall-reflect/src/report.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use std::collections::{BTreeMap, HashSet};

use crate::model::{
ConversationChunk, ReflectFilters, ReflectReport, ReflectScope, ReflectSummary, SourceSession,
TimelineMoment, TimelinePhase,
ConversationChunk, ReflectFilters, ReflectReport, ReflectScope, ReflectScopeKind,
ReflectSummary, SourceSession, TimelineMoment, TimelinePhase,
};
use crate::patterns::detect_observed_patterns;

Expand All @@ -13,6 +13,7 @@ pub fn build_reflect_report(
filters: &ReflectFilters,
) -> ReflectReport {
let scope = ReflectScope {
kind: filters.scope_kind,
project: filters.directory.clone(),
repo: filters.repo.clone(),
time_range: if filters.time_range.is_empty() {
Expand Down Expand Up @@ -115,7 +116,7 @@ pub fn build_reflect_report(

phases.push(TimelinePhase {
id: "phase-1".to_string(),
title: "Project conversation timeline".to_string(),
title: timeline_title(filters.scope_kind).to_string(),
start_at,
end_at,
summary: format!(
Expand Down Expand Up @@ -143,6 +144,13 @@ pub fn build_reflect_report(
}
}

fn timeline_title(scope_kind: ReflectScopeKind) -> &'static str {
match scope_kind {
ReflectScopeKind::Project => "Project conversation timeline",
ReflectScopeKind::Personal => "Personal conversation timeline",
}
}

fn compact_content(content: &str, max_chars: usize) -> String {
let collapsed = content.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.len() <= max_chars {
Expand Down Expand Up @@ -313,6 +321,47 @@ mod tests {
assert!(report.chunks.is_empty());
}

#[test]
fn reflect_report_includes_project_scope_kind_by_default() {
let sessions = vec![fixture_session(
"s1",
"codex",
"Scoped session",
1000,
vec![fixture_message("user", "hello", 0, 1100)],
)];
let filters = ReflectFilters {
directory: Some("/tmp/reflect-repo".to_string()),
..ReflectFilters::default()
};

let report = build_reflect_report(sessions, &filters);

assert_eq!(report.scope.kind.as_str(), "project");
let json = serde_json::to_value(&report).unwrap();
assert_eq!(json["scope"]["kind"], "project");
}

#[test]
fn reflect_report_labels_personal_timeline() {
let sessions = vec![fixture_session(
"s1",
"codex",
"Personal session",
1000,
vec![fixture_message("user", "hello", 0, 1100)],
)];
let filters = ReflectFilters {
scope_kind: crate::model::ReflectScopeKind::Personal,
time_range: "30d".to_string(),
..ReflectFilters::default()
};

let report = build_reflect_report(sessions, &filters);

assert_eq!(report.phases[0].title, "Personal conversation timeline");
}

#[test]
fn reflect_builds_timeline_across_sessions() {
let sessions = vec![
Expand Down
60 changes: 56 additions & 4 deletions extensions/recall-reflect/tests/reflect_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ fn reflect_cli_reads_export_jsonl_from_recall_bin() {
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"]["kind"], "project");
assert_eq!(json["scope"]["project"], "/tmp/repo");
assert_eq!(json["scope"]["repo"], "owner/repo");
assert_eq!(json["scope"]["time_range"], "week");
Expand Down Expand Up @@ -98,6 +99,7 @@ fn reflect_cli_defaults_unscoped_reflection_to_current_git_root() {
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"]["kind"], "project");
assert_eq!(json["scope"]["project"], repo.path().display().to_string());

let calls = fake.calls();
Expand All @@ -111,22 +113,72 @@ fn reflect_cli_defaults_unscoped_reflection_to_current_git_root() {
}

#[test]
fn reflect_cli_requires_explicit_scope_outside_git_worktree() {
fn reflect_cli_defaults_to_personal_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())
.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"]["kind"], "personal");
assert_eq!(json["scope"]["project"], serde_json::Value::Null);
assert_eq!(json["scope"]["repo"], serde_json::Value::Null);
assert_eq!(json["scope"]["time_range"], "30d");

let calls = fake.calls();
assert_eq!(calls, ["export --limit 0 --include metadata,messages --time 30d"]);
}

#[test]
fn reflect_cli_personal_overrides_git_root_scope() {
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(["--personal", "--time", "7d", "--format", "json"])
.output()
.unwrap();

assert!(!output.status.success(), "command should fail outside git without scope");
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"]["kind"], "personal");
assert_eq!(json["scope"]["project"], serde_json::Value::Null);
assert_eq!(json["scope"]["time_range"], "7d");

let calls = fake.calls();
assert_eq!(calls, ["export --limit 0 --include metadata,messages --time 7d"]);
}

#[test]
fn reflect_cli_rejects_personal_with_project_or_repo_scope() {
let fake = FakeRecall::new(JSONL_FIXTURE, 0, "");

let output = Command::new(env!("CARGO_BIN_EXE_recall-reflect"))
.env("RECALL_BIN", fake.script_path())
.args(["--personal", "--project", "/tmp/repo"])
.output()
.unwrap();

assert!(!output.status.success(), "command should reject conflicting scope flags");
assert!(output.stdout.is_empty(), "stdout must be empty on scope errors");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("--personal"), "stderr: {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");
assert!(fake.calls().is_empty(), "export must not run for invalid scope flags");
}

#[test]
Expand Down
5 changes: 2 additions & 3 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,9 @@ pub(crate) fn parse_since(s: &str) -> Option<i64> {
(n, 24 * 3600 * 1000i64)
} else if let Some(n) = s.strip_suffix('w') {
(n, 7 * 24 * 3600 * 1000i64)
} else if let Some(n) = s.strip_suffix('m') {
(n, 30 * 24 * 3600 * 1000i64)
} else {
return None;
let n = s.strip_suffix('m')?;
(n, 30 * 24 * 3600 * 1000i64)
};
let n: i64 = num_str.parse().ok()?;
let now = chrono::Utc::now().timestamp_millis();
Expand Down