commit 2e8ebb5352c4e466d15e153fb348b5c20bbd6635
parent 513c18f5b7b06d541bd5fb024f1d57225f3df5da
Author: William Casarin <jb55@jb55.com>
Date: Tue, 17 Feb 2026 12:37:48 -0800
use tags instead of JSON content for session state events
Session metadata (title, cwd, status) is now stored as tags rather
than serialized JSON in the content field. This avoids unnecessary
JSON parsing when reading session state, especially in the
query_replaceable_filtered predicate that checks for deleted sessions.
Also adds query_replaceable_filtered which takes a predicate closure
to reject notes during the fold pass.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
3 files changed, 42 insertions(+), 42 deletions(-)
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -1369,16 +1369,11 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
continue;
};
- let content = note.content();
- let Ok(json) = serde_json::from_str::<serde_json::Value>(content) else {
+ let Some(claude_sid) = session_events::get_tag_value(¬e, "d") else {
continue;
};
- let Some(claude_sid) = json["claude_session_id"].as_str() else {
- continue;
- };
-
- let status_str = json["status"].as_str().unwrap_or("idle");
+ let status_str = session_events::get_tag_value(¬e, "status").unwrap_or("idle");
// Skip deleted sessions entirely — don't create or keep them
if status_str == "deleted" {
@@ -1423,8 +1418,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
continue;
}
- let title = json["title"].as_str().unwrap_or("Untitled").to_string();
- let cwd_str = json["cwd"].as_str().unwrap_or("");
+ let title = session_events::get_tag_value(¬e, "title").unwrap_or("Untitled").to_string();
+ let cwd_str = session_events::get_tag_value(¬e, "cwd").unwrap_or("");
let cwd = std::path::PathBuf::from(cwd_str);
tracing::info!(
@@ -1474,7 +1469,6 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
agentic.perm_request_note_ids.extend(loaded.perm_request_note_ids);
agentic.seen_note_ids = loaded.note_ids;
// Set remote status
- let status_str = json["status"].as_str().unwrap_or("idle");
agentic.remote_status = AgentStatus::from_status_str(status_str);
// Set up live conversation subscription for remote sessions
diff --git a/crates/notedeck_dave/src/session_events.rs b/crates/notedeck_dave/src/session_events.rs
@@ -715,24 +715,20 @@ pub fn build_session_state_event(
.unwrap_or_default()
.as_secs();
- let content = serde_json::json!({
- "claude_session_id": claude_session_id,
- "title": title,
- "cwd": cwd,
- "status": status,
- "last_active": now,
- })
- .to_string();
-
let mut builder = NoteBuilder::new()
.kind(AI_SESSION_STATE_KIND)
- .content(&content)
+ .content("")
.options(NoteBuildOptions::default())
.created_at(now);
// Session identity (makes this a parameterized replaceable event)
builder = builder.start_tag().tag_str("d").tag_str(claude_session_id);
+ // Session metadata as tags
+ builder = builder.start_tag().tag_str("title").tag_str(title);
+ builder = builder.start_tag().tag_str("cwd").tag_str(cwd);
+ builder = builder.start_tag().tag_str("status").tag_str(status);
+
// Discoverability
builder = builder
.start_tag()
diff --git a/crates/notedeck_dave/src/session_loader.rs b/crates/notedeck_dave/src/session_loader.rs
@@ -23,6 +23,20 @@ pub fn query_replaceable(
txn: &Transaction,
filters: &[Filter],
) -> Vec<NoteKey> {
+ query_replaceable_filtered(ndb, txn, filters, |_| true)
+}
+
+/// Like `query_replaceable`, but with a predicate to filter notes.
+///
+/// The predicate is called on the latest revision of each d-tag group.
+/// If it returns false, that d-tag is removed from results (even if an
+/// older revision would have passed).
+pub fn query_replaceable_filtered(
+ ndb: &Ndb,
+ txn: &Transaction,
+ filters: &[Filter],
+ predicate: impl Fn(&nostrdb::Note) -> bool,
+) -> Vec<NoteKey> {
// Fold: for each d-tag value, track (created_at, NoteKey) of the latest
let best = ndb.fold(
txn,
@@ -41,7 +55,13 @@ pub fn query_replaceable(
}
}
- acc.insert(d_tag.to_string(), (created_at, note.key().expect("note key")));
+ if predicate(¬e) {
+ acc.insert(d_tag.to_string(), (created_at, note.key().expect("note key")));
+ } else {
+ // Latest revision rejected — remove any older revision we kept
+ acc.remove(d_tag);
+ }
+
acc
},
);
@@ -219,8 +239,8 @@ pub struct SessionState {
/// Load all session states from kind-31988 events in ndb.
///
-/// Uses `query_replaceable` to deduplicate by d-tag, keeping only the
-/// most recent revision of each session state.
+/// Uses `query_replaceable_filtered` to deduplicate by d-tag, keeping
+/// only the most recent non-deleted revision of each session state.
pub fn load_session_states(ndb: &Ndb, txn: &Transaction) -> Vec<SessionState> {
use crate::session_events::AI_SESSION_STATE_KIND;
@@ -229,7 +249,11 @@ pub fn load_session_states(ndb: &Ndb, txn: &Transaction) -> Vec<SessionState> {
.tags(["ai-session-state"], 't')
.build();
- let note_keys = query_replaceable(ndb, txn, &[filter]);
+ let not_deleted = |note: &nostrdb::Note| {
+ get_tag_value(note, "status") != Some("deleted")
+ };
+
+ let note_keys = query_replaceable_filtered(ndb, txn, &[filter], not_deleted);
let mut states = Vec::new();
for key in note_keys {
@@ -237,29 +261,15 @@ pub fn load_session_states(ndb: &Ndb, txn: &Transaction) -> Vec<SessionState> {
continue;
};
- let content = note.content();
- let Ok(json) = serde_json::from_str::<serde_json::Value>(content) else {
- continue;
- };
-
- let Some(claude_session_id) = json["claude_session_id"].as_str() else {
+ let Some(claude_session_id) = get_tag_value(¬e, "d") else {
continue;
};
- let status = json["status"].as_str().unwrap_or("idle");
-
- // Skip sessions that have been deleted
- if status == "deleted" {
- continue;
- }
-
- let title = json["title"].as_str().unwrap_or("Untitled").to_string();
- let cwd = json["cwd"].as_str().unwrap_or("").to_string();
states.push(SessionState {
claude_session_id: claude_session_id.to_string(),
- title,
- cwd,
- status: status.to_string(),
+ title: get_tag_value(¬e, "title").unwrap_or("Untitled").to_string(),
+ cwd: get_tag_value(¬e, "cwd").unwrap_or("").to_string(),
+ status: get_tag_value(¬e, "status").unwrap_or("idle").to_string(),
});
}