notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 12f409f57be857a73884740f0ec0864987d20457
parent f73985aa11b20e21bba6ba3c299c1838879369da
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 18 Feb 2026 17:07:02 -0800

dave: add "Summarize Thread" context menu action

Add a context menu button on notes that routes to Dave for thread
summarization. The thread is fetched from ndb (root note + all
replies), formatted as JSON in the system prompt, and sent to
Dave as a chat session with the root note displayed inline.

Also update OpenAI default model from gpt-4o to gpt-4.1-mini and
refresh the available models list.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck/src/note/context.rs | 4++++
Mcrates/notedeck_chrome/src/chrome.rs | 28++++++++++++++++++++++++++++
Mcrates/notedeck_columns/src/app.rs | 20++++++++++++++------
Mcrates/notedeck_columns/src/nav.rs | 12++++++++++++
Mcrates/notedeck_dave/src/config.rs | 6+++---
Mcrates/notedeck_dave/src/lib.rs | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/session.rs | 2+-
Mcrates/notedeck_dave/src/session_discovery.rs | 4+++-
Mcrates/notedeck_dave/src/session_loader.rs | 2+-
Mcrates/notedeck_dave/src/tools.rs | 102++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Mcrates/notedeck_ui/src/note/context.rs | 14++++++++++++++
Mcrates/notedeck_ui/src/note/mod.rs | 2++
12 files changed, 225 insertions(+), 60 deletions(-)

diff --git a/crates/notedeck/src/note/context.rs b/crates/notedeck/src/note/context.rs @@ -23,6 +23,7 @@ pub enum NoteContextSelection { CopyNeventLink, MuteUser, ReportUser, + SummarizeThread(NoteId), } #[derive(Debug, Eq, PartialEq, Clone)] @@ -111,6 +112,9 @@ impl NoteContextSelection { } } NoteContextSelection::ReportUser => {} + NoteContextSelection::SummarizeThread(_) => { + // Handled at Chrome level — routed to Dave + } } } } diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -217,6 +217,23 @@ impl Chrome { } } + fn get_dave_app(&mut self) -> Option<&mut Dave> { + for app in &mut self.apps { + if let NotedeckApp::Dave(dave) = app { + return Some(dave); + } + } + None + } + + fn switch_to_dave(&mut self) { + for (i, app) in self.apps.iter().enumerate() { + if let NotedeckApp::Dave(_) = app { + self.active = i as i32; + } + } + } + pub fn set_active(&mut self, app: i32) { self.active = app; } @@ -479,6 +496,17 @@ fn chrome_handle_app_action( } AppAction::Note(note_action) => { + // Intercept SummarizeThread — route to Dave instead of Columns + if let notedeck::NoteAction::Context(ref context) = note_action { + if let notedeck::NoteContextSelection::SummarizeThread(note_id) = context.action { + chrome.switch_to_dave(); + if let Some(dave) = chrome.get_dave_app() { + dave.summarize_thread(note_id); + } + return; + } + } + chrome.switch_to_columns(); let Some(columns) = chrome.get_columns_app() else { return; diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -711,7 +711,7 @@ fn render_damus_mobile( can_take_drag_from.extend(resp.can_take_drag_from()); let r = resp.process_render_nav_response(app, app_ctx, ui); - if let Some(r) = &r { + if let Some(r) = r { match r { ProcessNavResult::SwitchOccurred => { if !app.options.contains(AppOptions::TmpColumns) { @@ -725,18 +725,22 @@ fn render_damus_mobile( ProcessNavResult::SwitchAccount(pubkey) => { // Add as pubkey-only account if not already present - let kp = enostr::Keypair::only_pubkey(*pubkey); + let kp = enostr::Keypair::only_pubkey(pubkey); let _ = app_ctx.accounts.add_account(kp); let txn = nostrdb::Transaction::new(app_ctx.ndb).expect("txn"); app_ctx.accounts.select_account( - pubkey, + &pubkey, app_ctx.ndb, &txn, app_ctx.pool, ui.ctx(), ); } + + ProcessNavResult::ExternalNoteAction(note_action) => { + app_action = Some(AppAction::Note(note_action)); + } } } } @@ -993,7 +997,7 @@ fn timelines_view( for response in responses { let nav_result = response.process_render_nav_response(app, ctx, ui); - if let Some(nr) = &nav_result { + if let Some(nr) = nav_result { match nr { ProcessNavResult::SwitchOccurred => save_cols = true, @@ -1003,12 +1007,16 @@ fn timelines_view( ProcessNavResult::SwitchAccount(pubkey) => { // Add as pubkey-only account if not already present - let kp = enostr::Keypair::only_pubkey(*pubkey); + let kp = enostr::Keypair::only_pubkey(pubkey); let _ = ctx.accounts.add_account(kp); let txn = nostrdb::Transaction::new(ctx.ndb).expect("txn"); ctx.accounts - .select_account(pubkey, ctx.ndb, &txn, ctx.pool, ui.ctx()); + .select_account(&pubkey, ctx.ndb, &txn, ctx.pool, ui.ctx()); + } + + ProcessNavResult::ExternalNoteAction(note_action) => { + app_action = Some(AppAction::Note(note_action)); } } } diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -49,6 +49,8 @@ pub enum ProcessNavResult { SwitchOccurred, PfpClicked, SwitchAccount(enostr::Pubkey), + /// A note action that should be forwarded to Chrome as an AppAction + ExternalNoteAction(notedeck::NoteAction), } impl ProcessNavResult { @@ -540,6 +542,16 @@ fn process_render_nav_action( Some(RouterAction::GoBack) } RenderNavAction::NoteAction(note_action) => { + // SummarizeThread is handled by Chrome/Dave, not Columns + if let notedeck::NoteAction::Context(ref ctx_sel) = note_action { + if matches!( + ctx_sel.action, + notedeck::NoteContextSelection::SummarizeThread(_) + ) { + return Some(ProcessNavResult::ExternalNoteAction(note_action)); + } + } + let txn = Transaction::new(ctx.ndb).expect("txn"); crate::actionbar::execute_and_process_note_action( diff --git a/crates/notedeck_dave/src/config.rs b/crates/notedeck_dave/src/config.rs @@ -36,7 +36,7 @@ impl AiProvider { pub fn default_model(&self) -> &'static str { match self { - AiProvider::OpenAI => "gpt-4o", + AiProvider::OpenAI => "gpt-4.1-mini", AiProvider::Anthropic => "claude-sonnet-4-20250514", AiProvider::Ollama => "hhao/qwen2.5-coder-tools:latest", } @@ -59,7 +59,7 @@ impl AiProvider { pub fn available_models(&self) -> &'static [&'static str] { match self { - AiProvider::OpenAI => &["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo"], + AiProvider::OpenAI => &["gpt-4.1-mini", "gpt-4.1", "gpt-4.1-nano", "gpt-4o"], AiProvider::Anthropic => &[ "claude-sonnet-4-20250514", "claude-opus-4-20250514", @@ -208,7 +208,7 @@ impl Default for ModelConfig { let model = std::env::var("DAVE_MODEL") .ok() .unwrap_or_else(|| match backend { - BackendType::OpenAI => "gpt-4o".to_string(), + BackendType::OpenAI => "gpt-4.1-mini".to_string(), BackendType::Claude => "claude-sonnet-4.5".to_string(), BackendType::Remote => String::new(), }); diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -140,6 +140,9 @@ pub struct Dave { /// Sessions pending deletion state event publication. /// Populated in delete_session(), drained in the update loop where AppContext is available. pending_deletions: Vec<DeletedSessionInfo>, + /// Thread summaries pending processing. Queued by summarize_thread(), + /// resolved in update() where AppContext (ndb) is available. + pending_summaries: Vec<enostr::NoteId>, /// Local machine hostname, included in session state events. hostname: String, /// PNS relay URL (configurable via DAVE_RELAY env or settings UI). @@ -379,6 +382,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr session_state_sub: None, pending_perm_responses: Vec::new(), pending_deletions: Vec::new(), + pending_summaries: Vec::new(), hostname, pns_relay_url, } @@ -399,6 +403,83 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr self.settings = settings; } + /// Queue a thread summary request. The thread is fetched and formatted + /// in update() where AppContext (ndb) is available. + pub fn summarize_thread(&mut self, note_id: enostr::NoteId) { + self.pending_summaries.push(note_id); + } + + /// Fetch the thread from ndb, format it, and create a session with the prompt. + fn build_summary_session( + &mut self, + ndb: &nostrdb::Ndb, + note_id: &enostr::NoteId, + ) -> Option<SessionId> { + let txn = Transaction::new(ndb).ok()?; + + // Resolve to the root note of the thread + let clicked_note = ndb.get_note_by_id(&txn, note_id.bytes()).ok()?; + let root_id = nostrdb::NoteReply::new(clicked_note.tags()) + .root() + .map(|r| *r.id) + .unwrap_or(*note_id.bytes()); + + let root_note = ndb.get_note_by_id(&txn, &root_id).ok()?; + let root_simple = tools::note_to_simple(&txn, ndb, &root_note); + + // Fetch all replies referencing the root note + let filter = nostrdb::Filter::new().kinds([1]).event(&root_id).build(); + + let replies = ndb.query(&txn, &[filter], 500).ok().unwrap_or_default(); + + let mut simple_notes = vec![root_simple]; + for result in &replies { + if let Ok(note) = ndb.get_note_by_key(&txn, result.note_key) { + simple_notes.push(tools::note_to_simple(&txn, ndb, &note)); + } + } + + let thread_json = tools::format_simple_notes_json(&simple_notes); + let system = format!( + "You are summarizing a nostr thread. \ + Here is the thread data:\n\n{}\n\n\ + When referencing specific notes in your summary, call the \ + present_notes tool with their note_ids so the UI can display them inline.", + thread_json + ); + + let cwd = std::env::current_dir().unwrap_or_default(); + let id = update::create_session_with_cwd( + &mut self.session_manager, + &mut self.directory_picker, + &mut self.scene, + self.show_scene, + AiMode::Chat, + cwd, + &self.hostname, + ); + + if let Some(session) = self.session_manager.get_mut(id) { + session.chat.push(Message::System(system)); + + // Show the root note inline so the user can see what's being summarized + let present = tools::ToolCall::new( + "summarize-thread".to_string(), + tools::ToolCalls::PresentNotes(tools::PresentNotesCall { + note_ids: vec![enostr::NoteId::new(root_id)], + }), + ); + session.chat.push(Message::ToolCalls(vec![present])); + + session.chat.push(Message::User( + "Summarize this thread concisely.".to_string(), + )); + session.update_title_from_last_message(); + } + + Some(id) + } + /// Process incoming tokens from the ai backend for ALL sessions. /// Returns (sessions needing tool responses, events to publish to relays). fn process_events( @@ -1988,6 +2069,14 @@ impl notedeck::App for Dave { // Poll for external spawn-agent commands via IPC self.poll_ipc_commands(); + // Process pending thread summary requests + let pending = std::mem::take(&mut self.pending_summaries); + for note_id in pending { + if let Some(sid) = self.build_summary_session(ctx.ndb, &note_id) { + self.send_user_message_for(sid, ctx, ui.ctx()); + } + } + // One-time initialization on first update if !self.sessions_restored { self.sessions_restored = true; diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs @@ -489,7 +489,7 @@ impl ChatSession { for msg in self.chat.iter().rev() { match msg { Message::Assistant(_) | Message::CompactionComplete(_) => { - return AgentStatus::Done + return AgentStatus::Done; } Message::User(_) => return AgentStatus::Idle, // Waiting for response Message::Error(_) => return AgentStatus::Error, diff --git a/crates/notedeck_dave/src/session_discovery.rs b/crates/notedeck_dave/src/session_discovery.rs @@ -236,7 +236,9 @@ mod tests { #[test] fn test_extract_first_user_message_truncation() { - let long_content = serde_json::json!("Human: This is a very long message that should be truncated because it exceeds sixty characters in length"); + let long_content = serde_json::json!( + "Human: This is a very long message that should be truncated because it exceeds sixty characters in length" + ); let result = extract_first_user_message(&long_content); assert!(result.unwrap().ends_with("...")); } diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs @@ -104,7 +104,7 @@ pub fn load_session_messages(ndb: &Ndb, txn: &Transaction, session_id: &str) -> event_count: 0, permissions: PermissionTracker::new(), note_ids: HashSet::new(), - } + }; } }; diff --git a/crates/notedeck_dave/src/tools.rs b/crates/notedeck_dave/src/tools.rs @@ -498,15 +498,52 @@ impl QueryCall { } /// A simple note format for use when formatting -/// tool responses +/// tool responses and thread summaries #[derive(Debug, Serialize)] -struct SimpleNote { - note_id: String, - pubkey: String, - name: String, - content: String, - created_at: String, - note_kind: u64, // todo: add replying to +pub struct SimpleNote { + pub note_id: String, + pub pubkey: String, + pub name: String, + pub content: String, + pub created_at: String, + pub note_kind: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub reply_to: Option<String>, +} + +/// Convert a note to a SimpleNote for AI consumption. +pub fn note_to_simple(txn: &Transaction, ndb: &Ndb, note: &Note<'_>) -> SimpleNote { + let name = ndb + .get_profile_by_pubkey(txn, note.pubkey()) + .ok() + .and_then(|p| p.record().profile()) + .and_then(|p| p.name().or_else(|| p.display_name())) + .unwrap_or("Anonymous") + .to_string(); + + let created_at = DateTime::from_timestamp(note.created_at() as i64, 0) + .unwrap() + .format("%Y-%m-%d %H:%M:%S") + .to_string(); + + let reply_to = nostrdb::NoteReply::new(note.tags()) + .reply() + .map(|r| hex::encode(r.id)); + + SimpleNote { + note_id: hex::encode(note.id()), + pubkey: hex::encode(note.pubkey()), + name, + content: note.content().to_owned(), + created_at, + note_kind: note.kind() as u64, + reply_to, + } +} + +/// Format a list of SimpleNotes as JSON for AI consumption. +pub fn format_simple_notes_json(notes: &[SimpleNote]) -> String { + serde_json::to_string(&json!({"thread": notes})).unwrap() } /// Take the result of a tool response and present it to the ai so that @@ -521,37 +558,8 @@ fn format_tool_response_for_ai(txn: &Transaction, ndb: &Ndb, resp: &ToolResponse .notes .iter() .filter_map(|nkey| { - let Ok(note) = ndb.get_note_by_key(txn, NoteKey::new(*nkey)) else { - return None; - }; - - let name = ndb - .get_profile_by_pubkey(txn, note.pubkey()) - .ok() - .and_then(|p| p.record().profile()) - .and_then(|p| p.name().or_else(|| p.display_name())) - .unwrap_or("Anonymous") - .to_string(); - - let content = note.content().to_owned(); - let pubkey = hex::encode(note.pubkey()); - let note_kind = note.kind() as u64; - let note_id = hex::encode(note.id()); - - let created_at = { - let datetime = - DateTime::from_timestamp(note.created_at() as i64, 0).unwrap(); - datetime.format("%Y-%m-%d %H:%M:%S").to_string() - }; - - Some(SimpleNote { - note_id, - pubkey, - name, - content, - created_at, - note_kind, - }) + let note = ndb.get_note_by_key(txn, NoteKey::new(*nkey)).ok()?; + Some(note_to_simple(txn, ndb, &note)) }) .collect(); @@ -573,15 +581,13 @@ fn present_tool() -> Tool { name: "present_notes", parse_call: PresentNotesCall::parse, description: "A tool for presenting notes to the user for display. Should be called at the end of a response so that the UI can present the notes referred to in the previous message.", - arguments: vec![ - ToolArg { - name: "note_ids", - description: "A comma-separated list of hex note ids", - typ: ArgType::String, - required: true, - default: None - } - ] + arguments: vec![ToolArg { + name: "note_ids", + description: "A comma-separated list of hex note ids", + typ: ArgType::String, + required: true, + default: None, + }], } } diff --git a/crates/notedeck_ui/src/note/context.rs b/crates/notedeck_ui/src/note/context.rs @@ -1,4 +1,5 @@ use egui::{Rect, Vec2}; +use enostr::NoteId; use nostrdb::NoteKey; use notedeck::{tr, BroadcastContext, Localization, NoteContextSelection}; @@ -65,6 +66,7 @@ impl NoteContextButton { ui: &mut egui::Ui, i18n: &mut Localization, button_response: egui::Response, + note_id: NoteId, can_sign: bool, is_muted: bool, ) -> Option<NoteContextSelection> { @@ -76,6 +78,18 @@ impl NoteContextButton { if ui .button(tr!( i18n, + "Summarize Thread", + "Ask Dave to summarize this note's thread" + )) + .clicked() + { + context_selection = Some(NoteContextSelection::SummarizeThread(note_id)); + ui.close_menu(); + } + + if ui + .button(tr!( + i18n, "Copy Note Link", "Copy the damus.io note link for this note to clipboard" )) diff --git a/crates/notedeck_ui/src/note/mod.rs b/crates/notedeck_ui/src/note/mod.rs @@ -653,10 +653,12 @@ impl<'a, 'd> NoteView<'a, 'd> { .accounts .mute() .is_pk_muted(self.note.pubkey()); + let note_id = NoteId::new(*self.note.id()); if let Some(action) = NoteContextButton::menu( ui, self.note_context.i18n, resp.clone(), + note_id, can_sign, is_muted, ) {