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:
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, ¬e));
+ }
+ }
+
+ 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, ¬e_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, ¬e))
})
.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,
) {