From 21c320f1db10f38dfa5cacb4e6dc75761ec0d887 Mon Sep 17 00:00:00 2001 From: wsl2-2004 Date: Fri, 10 Jul 2026 06:53:24 +0800 Subject: [PATCH 1/3] feat(reflect): add broader reflection signals --- extensions/recall-reflect/src/model.rs | 27 ++ extensions/recall-reflect/src/render.rs | 68 +++++ extensions/recall-reflect/src/report.rs | 239 +++++++++++++++++- .../recall-reflect/tests/reflect_cli.rs | 32 +++ 4 files changed, 364 insertions(+), 2 deletions(-) diff --git a/extensions/recall-reflect/src/model.rs b/extensions/recall-reflect/src/model.rs index 0347799..0306310 100644 --- a/extensions/recall-reflect/src/model.rs +++ b/extensions/recall-reflect/src/model.rs @@ -111,6 +111,30 @@ pub struct ObservedPattern { pub discussion_prompt: String, } +#[derive(Debug, Clone, Serialize)] +pub struct SourceRoleSummary { + pub source: String, + pub observed_role: String, + pub sessions: usize, + pub timeline_moments: usize, + pub evidence_moments: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ProjectActivitySummary { + pub project: String, + pub sessions: usize, + pub timeline_moments: usize, + pub sources: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TaskShapeSummary { + pub shape: String, + pub timeline_moments: usize, + pub evidence_moments: Vec, +} + #[derive(Debug, Clone, Serialize)] pub struct ReflectProposalStub { pub note: String, @@ -122,6 +146,9 @@ pub struct ReflectReport { pub summary: ReflectSummary, pub chunks: Vec, pub phases: Vec, + pub source_roles: Vec, + pub project_summaries: Vec, + pub task_shapes: Vec, pub observed_patterns: Vec, pub proposals: Vec, pub coverage_note: Option, diff --git a/extensions/recall-reflect/src/render.rs b/extensions/recall-reflect/src/render.rs index 5a3d7e5..c3435b3 100644 --- a/extensions/recall-reflect/src/render.rs +++ b/extensions/recall-reflect/src/render.rs @@ -57,6 +57,43 @@ pub fn render_text(report: &ReflectReport) -> String { let _ = writeln!(out); } + if !report.source_roles.is_empty() { + let _ = writeln!(out, "Source Roles"); + for role in &report.source_roles { + let _ = writeln!( + out, + " - {}: {} ({} sessions, {} moments)", + role.source, role.observed_role, role.sessions, role.timeline_moments + ); + } + let _ = writeln!(out); + } + + if !report.project_summaries.is_empty() { + let _ = writeln!(out, "Project Activity"); + for project in &report.project_summaries { + let sources = if project.sources.is_empty() { + "-".to_string() + } else { + project.sources.join(", ") + }; + let _ = writeln!( + out, + " - {}: {} sessions, {} moments ({})", + project.project, project.sessions, project.timeline_moments, sources + ); + } + let _ = writeln!(out); + } + + if !report.task_shapes.is_empty() { + let _ = writeln!(out, "Task Shapes"); + for shape in &report.task_shapes { + let _ = writeln!(out, " - {}: {} moments", shape.shape, shape.timeline_moments); + } + let _ = writeln!(out); + } + if !report.observed_patterns.is_empty() { let _ = writeln!(out, "Discussion Prompts"); for pattern in &report.observed_patterns { @@ -130,4 +167,35 @@ mod tests { assert!(text.contains("hi there"), "must include assistant moment content"); assert!(!text.contains("session_events"), "must not contain raw event names"); } + + #[test] + fn reflect_text_output_includes_broader_signals() { + let sessions = vec![fixture_session( + "s1", + "codex", + "Planning session", + 1000, + vec![fixture_message( + "assistant", + "Plan the approach, outline the options, and analyze tradeoffs", + 0, + 1100, + )], + )]; + + let report = build_reflect_report(sessions, &ReflectFilters::default()); + let text = render_text(&report); + + let timeline = text.find("Timeline").unwrap(); + let source_roles = text.find("Source Roles").unwrap(); + let project_activity = text.find("Project Activity").unwrap(); + let task_shapes = text.find("Task Shapes").unwrap(); + + assert!(timeline < source_roles); + assert!(source_roles < project_activity); + assert!(project_activity < task_shapes); + assert!(text.contains("codex: Planning and analysis")); + assert!(text.contains("/tmp/reflect-repo")); + assert!(text.contains("planning")); + } } diff --git a/extensions/recall-reflect/src/report.rs b/extensions/recall-reflect/src/report.rs index fee7f85..bf200fd 100644 --- a/extensions/recall-reflect/src/report.rs +++ b/extensions/recall-reflect/src/report.rs @@ -1,8 +1,9 @@ use std::collections::{BTreeMap, HashSet}; use crate::model::{ - ConversationChunk, ReflectFilters, ReflectReport, ReflectScope, ReflectScopeKind, - ReflectSummary, SourceSession, TimelineMoment, TimelinePhase, + ConversationChunk, ProjectActivitySummary, ReflectFilters, ReflectReport, ReflectScope, + ReflectScopeKind, ReflectSummary, SourceRoleSummary, SourceSession, TaskShapeSummary, + TimelineMoment, TimelinePhase, }; use crate::patterns::detect_observed_patterns; @@ -30,6 +31,9 @@ pub fn build_reflect_report( summary: ReflectSummary { sessions: 0, timeline_moments: 0, phases: 0 }, chunks: Vec::new(), phases: Vec::new(), + source_roles: Vec::new(), + project_summaries: Vec::new(), + task_shapes: Vec::new(), observed_patterns: Vec::new(), proposals: Vec::new(), coverage_note: Some("No sessions matched the reflect scope.".to_string()), @@ -74,6 +78,9 @@ pub fn build_reflect_report( }); let timeline_moments_count = moments.len(); + let source_roles = build_source_roles(&sessions, &moments); + let project_summaries = build_project_summaries(&sessions, &moments); + let task_shapes = build_task_shapes(&moments); let mut sessions_by_id: BTreeMap> = BTreeMap::new(); for moment in &moments { @@ -138,6 +145,9 @@ pub fn build_reflect_report( }, chunks, phases, + source_roles, + project_summaries, + task_shapes, observed_patterns, proposals: Vec::new(), coverage_note: None, @@ -151,6 +161,121 @@ fn timeline_title(scope_kind: ReflectScopeKind) -> &'static str { } } +fn build_source_roles( + sessions: &[SourceSession], + moments: &[TimelineMoment], +) -> Vec { + let mut session_ids_by_source: BTreeMap> = BTreeMap::new(); + for session in sessions { + session_ids_by_source + .entry(session.source.clone()) + .or_default() + .insert(session.id.as_str()); + } + + let mut moments_by_source: BTreeMap> = BTreeMap::new(); + for moment in moments { + moments_by_source.entry(moment.source.clone()).or_default().push(moment); + } + + session_ids_by_source + .into_iter() + .map(|(source, session_ids)| { + let source_moments = moments_by_source.remove(&source).unwrap_or_default(); + SourceRoleSummary { + source, + observed_role: classify_source_role(&source_moments).to_string(), + sessions: session_ids.len(), + timeline_moments: source_moments.len(), + evidence_moments: source_moments.iter().take(3).map(|m| m.id.clone()).collect(), + } + }) + .collect() +} + +fn classify_source_role(moments: &[&TimelineMoment]) -> &'static str { + let planning_signals = ["plan", "approach", "outline", "analyze", "tradeoff"]; + let implementation_signals = ["implement", "fix", "test", "verify", "build", "migration"]; + let mut planning_hits = 0; + let mut implementation_hits = 0; + + for moment in moments { + let summary = moment.summary.to_lowercase(); + planning_hits += planning_signals.iter().filter(|signal| summary.contains(*signal)).count(); + implementation_hits += + implementation_signals.iter().filter(|signal| summary.contains(*signal)).count(); + } + + if implementation_hits >= planning_hits && implementation_hits > 0 { + "Implementation and verification" + } else if planning_hits > 0 { + "Planning and analysis" + } else { + "General conversation" + } +} + +fn build_project_summaries( + sessions: &[SourceSession], + moments: &[TimelineMoment], +) -> Vec { + let mut summaries: BTreeMap, usize, HashSet)> = BTreeMap::new(); + let mut project_by_session: BTreeMap<&str, String> = BTreeMap::new(); + + for session in sessions { + let project = session.directory.clone().unwrap_or_else(|| "-".to_string()); + project_by_session.insert(session.id.as_str(), project.clone()); + let entry = summaries.entry(project).or_default(); + entry.0.insert(session.id.as_str()); + entry.2.insert(session.source.clone()); + } + + for moment in moments { + if let Some(project) = project_by_session.get(moment.session_id.as_str()) { + summaries.entry(project.clone()).or_default().1 += 1; + } + } + + summaries + .into_iter() + .map(|(project, (session_ids, timeline_moments, sources))| ProjectActivitySummary { + project, + sessions: session_ids.len(), + timeline_moments, + sources: sources.into_iter().collect(), + }) + .collect() +} + +fn build_task_shapes(moments: &[TimelineMoment]) -> Vec { + let shape_signals: [(&str, &[&str]); 4] = [ + ("planning", &["plan", "approach", "outline", "strategy"]), + ("implementation", &["implement", "fix", "code", "migration", "test"]), + ("review", &["review", "diff", "verify", "check"]), + ("research", &["research", "docs", "compare", "investigate"]), + ]; + let mut evidence_by_shape: BTreeMap<&str, Vec> = BTreeMap::new(); + + for moment in moments { + let summary = moment.summary.to_lowercase(); + for (shape, signals) in &shape_signals { + if signals.iter().any(|signal| summary.contains(*signal)) { + evidence_by_shape.entry(*shape).or_default().push(moment.id.clone()); + break; + } + } + } + + evidence_by_shape + .into_iter() + .map(|(shape, evidence_moments)| TaskShapeSummary { + shape: shape.to_string(), + timeline_moments: evidence_moments.len(), + evidence_moments, + }) + .collect() +} + fn compact_content(content: &str, max_chars: usize) -> String { let collapsed = content.split_whitespace().collect::>().join(" "); if collapsed.len() <= max_chars { @@ -495,6 +620,116 @@ mod tests { assert!(report.observed_patterns.is_empty()); } + #[test] + fn reflect_report_summarizes_source_roles() { + let sessions = vec![ + fixture_session( + "s1", + "codex", + "Planning session", + 1000, + vec![fixture_message( + "assistant", + "Plan the approach, outline the options, and analyze tradeoffs", + 0, + 1100, + )], + ), + fixture_session( + "s2", + "opencode", + "Implementation session", + 2000, + vec![fixture_message( + "assistant", + "Implemented the migration, fixed the test, and verified the build", + 0, + 2100, + )], + ), + ]; + + let report = build_reflect_report(sessions, &ReflectFilters::default()); + + assert_eq!(report.source_roles.len(), 2); + assert_eq!(report.source_roles[0].source, "codex"); + assert_eq!(report.source_roles[0].observed_role, "Planning and analysis"); + assert_eq!(report.source_roles[0].sessions, 1); + assert_eq!(report.source_roles[0].timeline_moments, 1); + assert_eq!(report.source_roles[0].evidence_moments, ["s1:0"]); + assert_eq!(report.source_roles[1].source, "opencode"); + assert_eq!(report.source_roles[1].observed_role, "Implementation and verification"); + assert_eq!(report.source_roles[1].sessions, 1); + assert_eq!(report.source_roles[1].timeline_moments, 1); + assert_eq!(report.source_roles[1].evidence_moments, ["s2:0"]); + } + + #[test] + fn reflect_report_summarizes_project_activity() { + let mut app_a = fixture_session( + "s1", + "codex", + "App A session", + 1000, + vec![fixture_message("assistant", "Plan the app work", 0, 1100)], + ); + app_a.directory = Some("/tmp/app-a".to_string()); + let mut app_a_subdir = fixture_session( + "s2", + "opencode", + "App A subdir session", + 2000, + vec![fixture_message("assistant", "Implemented the app work", 0, 2100)], + ); + app_a_subdir.directory = Some("/tmp/app-a/subdir".to_string()); + let mut app_b = fixture_session( + "s3", + "codex", + "App B session", + 3000, + vec![fixture_message("assistant", "Review the release notes", 0, 3100)], + ); + app_b.directory = Some("/tmp/app-b".to_string()); + + let report = + build_reflect_report(vec![app_a, app_a_subdir, app_b], &ReflectFilters::default()); + + assert_eq!(report.project_summaries.len(), 3); + assert_eq!(report.project_summaries[0].project, "/tmp/app-a"); + assert_eq!(report.project_summaries[0].sessions, 1); + assert_eq!(report.project_summaries[0].timeline_moments, 1); + assert_eq!(report.project_summaries[0].sources, ["codex"]); + assert_eq!(report.project_summaries[1].project, "/tmp/app-a/subdir"); + assert_eq!(report.project_summaries[1].sources, ["opencode"]); + assert_eq!(report.project_summaries[2].project, "/tmp/app-b"); + assert_eq!(report.project_summaries[2].sources, ["codex"]); + } + + #[test] + fn reflect_report_summarizes_task_shapes() { + let sessions = vec![fixture_session( + "s1", + "codex", + "Mixed task session", + 1000, + vec![ + fixture_message("assistant", "Plan the implementation approach", 0, 1100), + fixture_message("assistant", "Implemented the migration and tests", 1, 1200), + fixture_message("assistant", "Review the diff and verify the build", 2, 1300), + ], + )]; + + let report = build_reflect_report(sessions, &ReflectFilters::default()); + + let shapes: Vec<&str> = + report.task_shapes.iter().map(|shape| shape.shape.as_str()).collect(); + assert_eq!(shapes, ["implementation", "planning", "review"]); + for shape in &report.task_shapes { + assert_eq!(shape.timeline_moments, 1); + assert_eq!(shape.evidence_moments.len(), 1); + } + } + #[test] fn reflect_excludes_low_level_transcript_logs_by_default() { let sessions = vec![fixture_session( diff --git a/extensions/recall-reflect/tests/reflect_cli.rs b/extensions/recall-reflect/tests/reflect_cli.rs index 24e2256..068c6a6 100644 --- a/extensions/recall-reflect/tests/reflect_cli.rs +++ b/extensions/recall-reflect/tests/reflect_cli.rs @@ -6,6 +6,10 @@ 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":[]} "#; +const BROADER_SIGNALS_JSONL_FIXTURE: &str = r#"{"schema_version":4,"record_type":"session","session":{"id":"s1","source":"codex","source_id":"source-s1","title":"Planning session","directory":"/tmp/app-a","repo_remote":null,"repo_slug":null,"repo_name":null,"started_at":1000,"updated_at":1200,"message_count":1,"entrypoint":null,"custom_title":null,"summary":null,"duration_minutes":null,"source_file_path":null},"messages":[{"seq":0,"role":"assistant","timestamp":1100,"content":"Plan the approach, outline the options, and analyze tradeoffs"}],"usage_events":[],"events":[]} +{"schema_version":4,"record_type":"session","session":{"id":"s2","source":"opencode","source_id":"source-s2","title":"Implementation session","directory":"/tmp/app-b","repo_remote":null,"repo_slug":null,"repo_name":null,"started_at":2000,"updated_at":2200,"message_count":1,"entrypoint":null,"custom_title":null,"summary":null,"duration_minutes":null,"source_file_path":null},"messages":[{"seq":0,"role":"assistant","timestamp":2100,"content":"Implemented the migration, fixed the test, and verified the build"}],"usage_events":[],"events":[]} +"#; + #[test] fn reflect_cli_reads_export_jsonl_from_recall_bin() { let fake = FakeRecall::new(JSONL_FIXTURE, 0, ""); @@ -48,6 +52,34 @@ fn reflect_cli_reads_export_jsonl_from_recall_bin() { ); } +#[test] +fn reflect_cli_json_exposes_broader_signals() { + let fake = FakeRecall::new(BROADER_SIGNALS_JSONL_FIXTURE, 0, ""); + + let output = Command::new(env!("CARGO_BIN_EXE_recall-reflect")) + .env("RECALL_BIN", fake.script_path()) + .args(["--personal", "--time", "30d", "--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["source_roles"].as_array().unwrap().len(), 2); + assert_eq!(json["source_roles"][0]["source"], "codex"); + assert_eq!(json["source_roles"][0]["observed_role"], "Planning and analysis"); + assert_eq!(json["source_roles"][1]["source"], "opencode"); + assert_eq!(json["source_roles"][1]["observed_role"], "Implementation and verification"); + assert_eq!(json["project_summaries"].as_array().unwrap().len(), 2); + assert_eq!(json["project_summaries"][0]["project"], "/tmp/app-a"); + assert_eq!(json["project_summaries"][1]["project"], "/tmp/app-b"); + assert!(json["task_shapes"].as_array().unwrap().len() >= 2); + + let calls = fake.calls(); + assert_eq!(calls, ["export --limit 0 --include metadata,messages --time 30d"]); +} + #[test] fn reflect_cli_syncs_selected_source_before_export() { let fake = FakeRecall::new(JSONL_FIXTURE, 0, ""); From 99411eea4bd2a98a29d219864250c8dc92dc12d9 Mon Sep 17 00:00:00 2001 From: wsl2-2004 Date: Fri, 10 Jul 2026 06:54:03 +0800 Subject: [PATCH 2/3] docs(reflect): mark broader signals implemented --- docs/extensions/reflect.md | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/docs/extensions/reflect.md b/docs/extensions/reflect.md index 86f8496..8d58511 100644 --- a/docs/extensions/reflect.md +++ b/docs/extensions/reflect.md @@ -140,8 +140,9 @@ full product direction: - when no project or repo is provided outside a Git repository, it defaults to personal scope over the recent `30d` window; - it renders project and personal conversation timelines, chunks long sessions, - filters low-level transcript artifacts, and emits a small discussion-prompt - layer. + filters low-level transcript artifacts, emits deterministic source-role, + project-activity, and task-shape summaries, and emits a small + discussion-prompt layer. The implemented baseline does not yet support a TUI output mode, proposal persistence, or instruction-file patch application. @@ -307,9 +308,26 @@ conversation: { "source": "claude-code", "observed_role": "Exploration and broad planning", + "sessions": 2, + "timeline_moments": 6, "evidence_moments": ["moment-1", "moment-3"] } ], + "project_summaries": [ + { + "project": "/path/to/repo", + "sessions": 3, + "timeline_moments": 12, + "sources": ["codex", "claude-code"] + } + ], + "task_shapes": [ + { + "shape": "planning", + "timeline_moments": 4, + "evidence_moments": ["moment-1", "moment-5"] + } + ], "timeline": [ { "id": "moment-1", @@ -369,7 +387,8 @@ the data plane and query protocol. - `recall-reflect` is an official extension binary with manifest support. - Project and personal reflection work from `metadata,messages`. - Text and JSON output render the selected scope kind, project/repo scope, - timeline phases, observed patterns, and proposal stubs. + timeline phases, source-role summaries, project-activity summaries, task-shape + summaries, observed patterns, and proposal stubs. - Low-level transcript artifacts are hidden or summarized by default. ### Completed Milestone: Personal And Project Scopes @@ -381,17 +400,21 @@ the data plane and query protocol. the recent `30d` time window. - Text and JSON output include a stable scope kind. -### Next Milestone: Broader Reflection Signals +### Completed Milestone: Broader Reflection Signals - Personal reflection provides deterministic source, project, and task-shape summaries without reading SQLite directly. -### Later Milestone: Interactive Review +### Next Milestone: Interactive Review -- `recall reflect` opens the TUI by default. +- `recall reflect` can open a TUI review workbench. - TUI shows actionable observations with evidence ids. - TUI previews instruction-file patches. - Apply writes only approved instruction-file patches. + +### Later Milestone: Interactive Review Polish + +- `recall reflect` opens the TUI by default. - `--format json` emits machine-readable scope, observations, evidence, and proposal stubs. From 6c196551b77bcbcc83c26b3edaaf4a97d1a6e174 Mon Sep 17 00:00:00 2001 From: wsl2-2004 Date: Fri, 10 Jul 2026 07:08:11 +0800 Subject: [PATCH 3/3] fix(reflect): stabilize project source summaries --- docs/extensions/reflect.md | 13 ++++++++----- extensions/recall-reflect/src/report.rs | 22 +++++++++++++++++----- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/docs/extensions/reflect.md b/docs/extensions/reflect.md index 8d58511..cfea059 100644 --- a/docs/extensions/reflect.md +++ b/docs/extensions/reflect.md @@ -280,11 +280,14 @@ Recall reflect for : 1. Scope summary 2. Timeline -3. Observed workflow patterns -4. Discussion prompts -5. Calibration targets confirmed by the user -6. Optional proposals -7. Follow-up checks from previous calibrations +3. Source roles +4. Project activity +5. Task shapes +6. Observed workflow patterns +7. Discussion prompts +8. Calibration targets confirmed by the user +9. Optional proposals +10. Follow-up checks from previous calibrations ``` The main timeline should read like a concise history of human intent and agent diff --git a/extensions/recall-reflect/src/report.rs b/extensions/recall-reflect/src/report.rs index bf200fd..34fe59b 100644 --- a/extensions/recall-reflect/src/report.rs +++ b/extensions/recall-reflect/src/report.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use crate::model::{ ConversationChunk, ProjectActivitySummary, ReflectFilters, ReflectReport, ReflectScope, @@ -219,7 +219,7 @@ fn build_project_summaries( sessions: &[SourceSession], moments: &[TimelineMoment], ) -> Vec { - let mut summaries: BTreeMap, usize, HashSet)> = BTreeMap::new(); + let mut summaries: BTreeMap, usize, BTreeSet)> = BTreeMap::new(); let mut project_by_session: BTreeMap<&str, String> = BTreeMap::new(); for session in sessions { @@ -690,9 +690,19 @@ mod tests { vec![fixture_message("assistant", "Review the release notes", 0, 3100)], ); app_b.directory = Some("/tmp/app-b".to_string()); + let mut app_b_second_source = fixture_session( + "s4", + "opencode", + "App B second source session", + 4000, + vec![fixture_message("assistant", "Check the release notes", 0, 4100)], + ); + app_b_second_source.directory = Some("/tmp/app-b".to_string()); - let report = - build_reflect_report(vec![app_a, app_a_subdir, app_b], &ReflectFilters::default()); + let report = build_reflect_report( + vec![app_a, app_a_subdir, app_b, app_b_second_source], + &ReflectFilters::default(), + ); assert_eq!(report.project_summaries.len(), 3); assert_eq!(report.project_summaries[0].project, "/tmp/app-a"); @@ -702,7 +712,9 @@ mod tests { assert_eq!(report.project_summaries[1].project, "/tmp/app-a/subdir"); assert_eq!(report.project_summaries[1].sources, ["opencode"]); assert_eq!(report.project_summaries[2].project, "/tmp/app-b"); - assert_eq!(report.project_summaries[2].sources, ["codex"]); + assert_eq!(report.project_summaries[2].sessions, 2); + assert_eq!(report.project_summaries[2].timeline_moments, 2); + assert_eq!(report.project_summaries[2].sources, ["codex", "opencode"]); } #[test]