notedeck

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

session_events.rs (52698B)


      1 //! Convert parsed JSONL lines into kind-1988 nostr events.
      2 //!
      3 //! Each JSONL line becomes one or more nostr events. Assistant messages with
      4 //! mixed content (text + tool_use blocks) are split into separate events.
      5 //! Events are threaded using NIP-10 `e` tags with root/reply markers.
      6 
      7 use crate::session_jsonl::{self, ContentBlock, JsonlLine};
      8 use nostrdb::{NoteBuildOptions, NoteBuilder};
      9 use std::collections::HashMap;
     10 
     11 /// Nostr event kind for AI conversation notes.
     12 pub const AI_CONVERSATION_KIND: u32 = 1988;
     13 
     14 /// Nostr event kind for source-data companion events (archive).
     15 /// Each 1989 event carries the raw JSONL for one line, linked to the
     16 /// corresponding 1988 event via an `e` tag.
     17 pub const AI_SOURCE_DATA_KIND: u32 = 1989;
     18 
     19 /// Nostr event kind for AI session state (parameterized replaceable, NIP-33).
     20 /// One event per session, auto-replaced by nostrdb on update.
     21 /// `d` tag = claude_session_id.
     22 pub const AI_SESSION_STATE_KIND: u32 = 31988;
     23 
     24 /// Nostr event kind for AI session commands (parameterized replaceable, NIP-33).
     25 /// Fire-and-forget commands to create sessions on remote hosts.
     26 /// `d` tag = command UUID.
     27 pub const AI_SESSION_COMMAND_KIND: u32 = 31989;
     28 
     29 /// Extract the value of a named tag from a note.
     30 pub fn get_tag_value<'a>(note: &'a nostrdb::Note<'a>, tag_name: &str) -> Option<&'a str> {
     31     for tag in note.tags() {
     32         if tag.count() < 2 {
     33             continue;
     34         }
     35         let Some(name) = tag.get_str(0) else {
     36             continue;
     37         };
     38         if name != tag_name {
     39             continue;
     40         }
     41         return tag.get_str(1);
     42     }
     43     None
     44 }
     45 
     46 /// A built nostr event ready for ingestion and relay publishing.
     47 #[derive(Debug)]
     48 pub struct BuiltEvent {
     49     /// The bare event JSON `{…}` — for relay publishing and ndb ingestion.
     50     pub note_json: String,
     51     /// The 32-byte note ID (from the signed event).
     52     pub note_id: [u8; 32],
     53     /// The nostr event kind (1988 or 1989).
     54     pub kind: u32,
     55 }
     56 
     57 impl BuiltEvent {
     58     /// Format as `["EVENT", {…}]` for ndb ingestion via `process_event_with`.
     59     pub fn to_event_json(&self) -> String {
     60         format!("[\"EVENT\", {}]", self.note_json)
     61     }
     62 }
     63 
     64 /// Wrap an inner event in a kind-1080 PNS envelope for relay publishing.
     65 ///
     66 /// Encrypts the inner event JSON with the PNS conversation key and signs
     67 /// the outer event with the PNS keypair. Returns the kind-1080 event JSON.
     68 pub fn wrap_pns(
     69     inner_json: &str,
     70     pns_keys: &enostr::pns::PnsKeys,
     71 ) -> Result<String, EventBuildError> {
     72     let ciphertext = enostr::pns::encrypt(&pns_keys.conversation_key, inner_json)
     73         .map_err(|e| EventBuildError::Serialize(format!("PNS encrypt: {e}")))?;
     74 
     75     let pns_secret = pns_keys.keypair.secret_key.secret_bytes();
     76     let builder = init_note_builder(enostr::pns::PNS_KIND, &ciphertext, Some(now_secs()));
     77     let event = finalize_built_event(builder, &pns_secret, enostr::pns::PNS_KIND)?;
     78     Ok(event.note_json)
     79 }
     80 
     81 /// Maintains threading state across a session's events.
     82 pub struct ThreadingState {
     83     /// Maps JSONL uuid → nostr note ID (32 bytes).
     84     uuid_to_note_id: HashMap<String, [u8; 32]>,
     85     /// The note ID of the first event in the session (root).
     86     root_note_id: Option<[u8; 32]>,
     87     /// The note ID of the most recently built event.
     88     last_note_id: Option<[u8; 32]>,
     89     /// Monotonic sequence counter for unambiguous ordering.
     90     seq: u32,
     91     /// Last seen session ID (carried forward for lines that lack it).
     92     session_id: Option<String>,
     93     /// Last seen timestamp in seconds (carried forward for lines that lack it).
     94     last_timestamp: Option<u64>,
     95 }
     96 
     97 impl Default for ThreadingState {
     98     fn default() -> Self {
     99         Self::new()
    100     }
    101 }
    102 
    103 impl ThreadingState {
    104     pub fn new() -> Self {
    105         Self {
    106             uuid_to_note_id: HashMap::new(),
    107             root_note_id: None,
    108             last_note_id: None,
    109             seq: 0,
    110             session_id: None,
    111             last_timestamp: None,
    112         }
    113     }
    114 
    115     /// The current sequence number.
    116     pub fn seq(&self) -> u32 {
    117         self.seq
    118     }
    119 
    120     /// Update session context from a JSONL line (session_id, timestamp).
    121     fn update_context(&mut self, line: &JsonlLine) {
    122         if let Some(sid) = line.session_id() {
    123             self.session_id = Some(sid.to_string());
    124         }
    125         if let Some(ts) = line.timestamp_secs() {
    126             self.last_timestamp = Some(ts);
    127         }
    128     }
    129 
    130     /// Get the session ID for the current line, falling back to the last seen.
    131     fn session_id_for(&self, line: &JsonlLine) -> Option<String> {
    132         line.session_id()
    133             .map(|s| s.to_string())
    134             .or_else(|| self.session_id.clone())
    135     }
    136 
    137     /// Get the timestamp for the current line, falling back to the last seen.
    138     fn timestamp_for(&self, line: &JsonlLine) -> Option<u64> {
    139         line.timestamp_secs().or(self.last_timestamp)
    140     }
    141 
    142     /// Seed threading state from existing events (e.g. loaded from ndb).
    143     ///
    144     /// Sets root and last note IDs so that subsequent live events
    145     /// thread correctly as replies to the existing conversation.
    146     pub fn seed(&mut self, root_note_id: [u8; 32], last_note_id: [u8; 32], event_count: u32) {
    147         self.root_note_id = Some(root_note_id);
    148         self.last_note_id = Some(last_note_id);
    149         self.seq = event_count;
    150     }
    151 
    152     /// Record a built event's note ID, associated with a JSONL uuid.
    153     ///
    154     /// `can_be_root`: if true, this event may become the conversation root.
    155     /// Metadata events (queue-operation, progress, etc.) should pass false
    156     /// so they don't become the root of the threading chain.
    157     pub fn record(&mut self, uuid: Option<&str>, note_id: [u8; 32], can_be_root: bool) {
    158         if can_be_root && self.root_note_id.is_none() {
    159             self.root_note_id = Some(note_id);
    160         }
    161         if let Some(uuid) = uuid {
    162             self.uuid_to_note_id.insert(uuid.to_string(), note_id);
    163         }
    164         self.last_note_id = Some(note_id);
    165         self.seq += 1;
    166     }
    167 }
    168 
    169 /// Get the current Unix timestamp in seconds.
    170 fn now_secs() -> u64 {
    171     std::time::SystemTime::now()
    172         .duration_since(std::time::UNIX_EPOCH)
    173         .unwrap_or_default()
    174         .as_secs()
    175 }
    176 
    177 /// Initialize a NoteBuilder with kind, content, and optional timestamp.
    178 fn init_note_builder(kind: u32, content: &str, timestamp: Option<u64>) -> NoteBuilder<'_> {
    179     let mut builder = NoteBuilder::new()
    180         .kind(kind)
    181         .content(content)
    182         .options(NoteBuildOptions::default());
    183 
    184     if let Some(ts) = timestamp {
    185         builder = builder.created_at(ts);
    186     }
    187 
    188     builder
    189 }
    190 
    191 /// Sign, build, and serialize a NoteBuilder into a BuiltEvent.
    192 fn finalize_built_event(
    193     builder: NoteBuilder,
    194     secret_key: &[u8; 32],
    195     kind: u32,
    196 ) -> Result<BuiltEvent, EventBuildError> {
    197     let note = builder
    198         .sign(secret_key)
    199         .build()
    200         .ok_or_else(|| EventBuildError::Build("NoteBuilder::build returned None".to_string()))?;
    201 
    202     let note_id: [u8; 32] = *note.id();
    203 
    204     let note_json = note
    205         .json()
    206         .map_err(|e| EventBuildError::Serialize(format!("{:?}", e)))?;
    207 
    208     Ok(BuiltEvent {
    209         note_json,
    210         note_id,
    211         kind,
    212     })
    213 }
    214 
    215 /// Whether a role represents a conversation message (not metadata).
    216 pub fn is_conversation_role(role: &str) -> bool {
    217     matches!(role, "user" | "assistant" | "tool_call" | "tool_result")
    218 }
    219 
    220 /// Build nostr events from a single JSONL line.
    221 ///
    222 /// Returns one or more events. Assistant messages with mixed content blocks
    223 /// (text + tool_use) are split into multiple events, one per block.
    224 ///
    225 /// `secret_key` is the 32-byte secret key for signing events.
    226 pub fn build_events(
    227     line: &JsonlLine,
    228     threading: &mut ThreadingState,
    229     secret_key: &[u8; 32],
    230 ) -> Result<Vec<BuiltEvent>, EventBuildError> {
    231     // Resolve session_id and timestamp with fallback to last seen values,
    232     // then update context for subsequent lines.
    233     let session_id = threading.session_id_for(line);
    234     let timestamp = threading.timestamp_for(line);
    235     threading.update_context(line);
    236 
    237     let msg = line.message();
    238     let is_assistant = line.line_type() == Some("assistant");
    239 
    240     // Check if this is an assistant message with multiple content blocks
    241     // that should be split into separate events
    242     let blocks: Vec<ContentBlock<'_>> = if is_assistant {
    243         msg.as_ref().map(|m| m.content_blocks()).unwrap_or_default()
    244     } else {
    245         vec![]
    246     };
    247 
    248     let should_split = is_assistant && blocks.len() > 1;
    249 
    250     let mut events = if should_split {
    251         // Build one event per content block
    252         let total = blocks.len();
    253         let mut events = Vec::with_capacity(total);
    254         for (i, block) in blocks.iter().enumerate() {
    255             let content = session_jsonl::display_content_for_block(block);
    256             let role = match block {
    257                 ContentBlock::Text(_) => "assistant",
    258                 ContentBlock::ToolUse { .. } => "tool_call",
    259                 ContentBlock::ToolResult { .. } => "tool_result",
    260             };
    261             let tool_id = match block {
    262                 ContentBlock::ToolUse { id, .. } => Some(*id),
    263                 ContentBlock::ToolResult { tool_use_id, .. } => Some(*tool_use_id),
    264                 _ => None,
    265             };
    266             let tool_name = match block {
    267                 ContentBlock::ToolUse { name, .. } => Some(*name),
    268                 ContentBlock::ToolResult { tool_use_id, .. } => {
    269                     // Look up tool name from a prior ToolUse block with matching id
    270                     blocks.iter().find_map(|b| match b {
    271                         ContentBlock::ToolUse { id, name, .. } if *id == *tool_use_id => {
    272                             Some(*name)
    273                         }
    274                         _ => None,
    275                     })
    276                 }
    277                 _ => None,
    278             };
    279 
    280             let event = build_single_event(
    281                 Some(line),
    282                 &content,
    283                 role,
    284                 "claude-code",
    285                 Some((i, total)),
    286                 tool_id,
    287                 tool_name,
    288                 session_id.as_deref(),
    289                 None,
    290                 timestamp,
    291                 threading,
    292                 secret_key,
    293             )?;
    294             threading.record(line.uuid(), event.note_id, is_conversation_role(role));
    295             events.push(event);
    296         }
    297         events
    298     } else {
    299         // Single event for the line
    300         let content = session_jsonl::extract_display_content(line);
    301         let role = line.role().unwrap_or("unknown");
    302 
    303         // Extract tool_id and tool_name from single-block messages
    304         let (tool_id, tool_name) = msg
    305             .as_ref()
    306             .and_then(|m| {
    307                 let blocks = m.content_blocks();
    308                 if blocks.len() == 1 {
    309                     match &blocks[0] {
    310                         ContentBlock::ToolUse { id, name, .. } => {
    311                             Some((id.to_string(), Some(name.to_string())))
    312                         }
    313                         ContentBlock::ToolResult { tool_use_id, .. } => {
    314                             Some((tool_use_id.to_string(), None))
    315                         }
    316                         _ => None,
    317                     }
    318                 } else {
    319                     None
    320                 }
    321             })
    322             .map_or((None, None), |(id, name)| (Some(id), name));
    323 
    324         let event = build_single_event(
    325             Some(line),
    326             &content,
    327             role,
    328             "claude-code",
    329             None,
    330             tool_id.as_deref(),
    331             tool_name.as_deref(),
    332             session_id.as_deref(),
    333             None,
    334             timestamp,
    335             threading,
    336             secret_key,
    337         )?;
    338         threading.record(line.uuid(), event.note_id, is_conversation_role(role));
    339         vec![event]
    340     };
    341 
    342     // Build a kind-1989 source-data companion event linked to the first 1988 event.
    343     let first_note_id = events[0].note_id;
    344     let source_data_event = build_source_data_event(
    345         line,
    346         &first_note_id,
    347         threading.seq() - 1,
    348         session_id.as_deref(),
    349         timestamp,
    350         secret_key,
    351     )?;
    352     events.push(source_data_event);
    353 
    354     Ok(events)
    355 }
    356 
    357 #[derive(Debug)]
    358 pub enum EventBuildError {
    359     Build(String),
    360     Serialize(String),
    361 }
    362 
    363 impl std::fmt::Display for EventBuildError {
    364     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    365         match self {
    366             EventBuildError::Build(e) => write!(f, "failed to build note: {}", e),
    367             EventBuildError::Serialize(e) => write!(f, "failed to serialize event: {}", e),
    368         }
    369     }
    370 }
    371 
    372 /// Build a kind-1989 source-data companion event.
    373 ///
    374 /// Contains the raw JSONL line and links to the corresponding 1988 event.
    375 /// Does NOT participate in threading (no root/reply, no seq increment).
    376 fn build_source_data_event(
    377     line: &JsonlLine,
    378     conversation_note_id: &[u8; 32],
    379     seq: u32,
    380     session_id: Option<&str>,
    381     timestamp: Option<u64>,
    382     secret_key: &[u8; 32],
    383 ) -> Result<BuiltEvent, EventBuildError> {
    384     let raw_json = line.to_json();
    385     let seq_str = seq.to_string();
    386 
    387     let mut builder = init_note_builder(AI_SOURCE_DATA_KIND, "", timestamp);
    388 
    389     // Link to the corresponding 1988 event
    390     builder = builder
    391         .start_tag()
    392         .tag_str("e")
    393         .tag_id(conversation_note_id);
    394 
    395     if let Some(session_id) = session_id {
    396         builder = builder.start_tag().tag_str("d").tag_str(session_id);
    397     }
    398 
    399     // Same seq as the first 1988 event from this line
    400     builder = builder.start_tag().tag_str("seq").tag_str(&seq_str);
    401 
    402     // The raw JSONL data
    403     builder = builder
    404         .start_tag()
    405         .tag_str("source-data")
    406         .tag_str(&raw_json);
    407 
    408     finalize_built_event(builder, secret_key, AI_SOURCE_DATA_KIND)
    409 }
    410 
    411 /// Build a single kind-1988 nostr event.
    412 ///
    413 /// When `line` is provided (archive path), extracts slug, version, model,
    414 /// line_type, and cwd from the JSONL line. When `None` (live path), only
    415 /// uses the explicitly passed parameters.
    416 ///
    417 /// `split_index`: `Some((i, total))` when this event is part of a split
    418 /// assistant message.
    419 ///
    420 /// `tool_id`: The tool use/result ID for tool_call and tool_result events.
    421 ///
    422 /// `tool_name`: The tool name (e.g. "Bash", "Read") for tool_call and tool_result events.
    423 #[allow(clippy::too_many_arguments)]
    424 fn build_single_event(
    425     line: Option<&JsonlLine>,
    426     content: &str,
    427     role: &str,
    428     source: &str,
    429     split_index: Option<(usize, usize)>,
    430     tool_id: Option<&str>,
    431     tool_name: Option<&str>,
    432     session_id: Option<&str>,
    433     cwd: Option<&str>,
    434     timestamp: Option<u64>,
    435     threading: &ThreadingState,
    436     secret_key: &[u8; 32],
    437 ) -> Result<BuiltEvent, EventBuildError> {
    438     let mut builder = init_note_builder(AI_CONVERSATION_KIND, content, timestamp);
    439 
    440     // -- Session identity tags --
    441     if let Some(session_id) = session_id {
    442         builder = builder.start_tag().tag_str("d").tag_str(session_id);
    443     }
    444     if let Some(slug) = line.and_then(|l| l.slug()) {
    445         builder = builder.start_tag().tag_str("session-slug").tag_str(slug);
    446     }
    447 
    448     // -- Threading tags (NIP-10) --
    449     if let Some(root_id) = threading.root_note_id {
    450         builder = builder
    451             .start_tag()
    452             .tag_str("e")
    453             .tag_id(&root_id)
    454             .tag_str("")
    455             .tag_str("root");
    456     }
    457     if let Some(reply_id) = threading.last_note_id {
    458         builder = builder
    459             .start_tag()
    460             .tag_str("e")
    461             .tag_id(&reply_id)
    462             .tag_str("")
    463             .tag_str("reply");
    464     }
    465 
    466     // -- Sequence number (monotonic, for unambiguous ordering) --
    467     let seq_str = threading.seq.to_string();
    468     builder = builder.start_tag().tag_str("seq").tag_str(&seq_str);
    469 
    470     // -- Message metadata tags --
    471     builder = builder.start_tag().tag_str("source").tag_str(source);
    472 
    473     if let Some(version) = line.and_then(|l| l.version()) {
    474         builder = builder
    475             .start_tag()
    476             .tag_str("source-version")
    477             .tag_str(version);
    478     }
    479 
    480     builder = builder.start_tag().tag_str("role").tag_str(role);
    481 
    482     // Model tag (for assistant messages)
    483     if let Some(model) = line.and_then(|l| l.message()).and_then(|m| m.model()) {
    484         builder = builder.start_tag().tag_str("model").tag_str(model);
    485     }
    486 
    487     if let Some(line_type) = line.and_then(|l| l.line_type()) {
    488         builder = builder.start_tag().tag_str("turn-type").tag_str(line_type);
    489     }
    490 
    491     // -- CWD tag --
    492     let resolved_cwd = cwd.or_else(|| line.and_then(|l| l.cwd()));
    493     if let Some(cwd) = resolved_cwd {
    494         builder = builder.start_tag().tag_str("cwd").tag_str(cwd);
    495     }
    496 
    497     // -- Split tag (for split assistant messages) --
    498     if let Some((i, total)) = split_index {
    499         let split_str = format!("{}/{}", i, total);
    500         builder = builder.start_tag().tag_str("split").tag_str(&split_str);
    501     }
    502 
    503     // -- Tool ID tag --
    504     if let Some(tid) = tool_id {
    505         builder = builder.start_tag().tag_str("tool-id").tag_str(tid);
    506     }
    507 
    508     // -- Tool name tag --
    509     if let Some(tn) = tool_name {
    510         builder = builder.start_tag().tag_str("tool-name").tag_str(tn);
    511     }
    512 
    513     // -- Discoverability --
    514     builder = builder.start_tag().tag_str("t").tag_str("ai-conversation");
    515 
    516     finalize_built_event(builder, secret_key, AI_CONVERSATION_KIND)
    517 }
    518 
    519 /// Build a kind-1988 event for a live conversation message.
    520 ///
    521 /// Unlike `build_events()` which works from JSONL lines, this builds directly
    522 /// from role + content strings. No kind-1989 source-data events are created.
    523 ///
    524 /// Calls `threading.record()` internally.
    525 #[allow(clippy::too_many_arguments)]
    526 pub fn build_live_event(
    527     content: &str,
    528     role: &str,
    529     session_id: &str,
    530     cwd: Option<&str>,
    531     tool_id: Option<&str>,
    532     tool_name: Option<&str>,
    533     threading: &mut ThreadingState,
    534     secret_key: &[u8; 32],
    535 ) -> Result<BuiltEvent, EventBuildError> {
    536     let event = build_single_event(
    537         None,
    538         content,
    539         role,
    540         "notedeck-dave",
    541         None,
    542         tool_id,
    543         tool_name,
    544         Some(session_id),
    545         cwd,
    546         Some(now_secs()),
    547         threading,
    548         secret_key,
    549     )?;
    550 
    551     threading.record(None, event.note_id, true);
    552     Ok(event)
    553 }
    554 
    555 /// Build a kind-1988 permission request event.
    556 ///
    557 /// Published to relays so remote clients (phone) can see pending permission
    558 /// requests and respond. Tags include `perm-id` (UUID), `tool-name`, and
    559 /// `t: ai-permission` for filtering.
    560 ///
    561 /// Maximum serialized size for tool_input in permission request events.
    562 /// Keeps the final PNS-wrapped event well under typical relay limits
    563 /// (~64KB). Budget: 40KB content + ~500B inner event overhead + ~500B
    564 /// PNS outer overhead, with 1.33x base64 expansion ≈ 54KB total.
    565 const MAX_TOOL_INPUT_BYTES: usize = 40_000;
    566 
    567 /// Truncate large string values in a tool_input JSON object so that the
    568 /// serialized result fits within `max_bytes`.
    569 ///
    570 /// Only modifies top-level string fields in an Object value.  Fields are
    571 /// truncated proportionally based on how much they exceed their share of
    572 /// the budget.  A `"_truncated": true` flag is added when any field is
    573 /// shortened.
    574 fn truncate_tool_input(tool_input: &serde_json::Value, max_bytes: usize) -> serde_json::Value {
    575     use serde_json::Value;
    576 
    577     let serialized = serde_json::to_string(tool_input).unwrap_or_default();
    578     if serialized.len() <= max_bytes {
    579         return tool_input.clone();
    580     }
    581 
    582     let obj = match tool_input.as_object() {
    583         Some(o) => o,
    584         None => return tool_input.clone(),
    585     };
    586 
    587     // Collect string field names sorted largest-first so we know what to trim
    588     let mut string_fields: Vec<(&str, usize)> = obj
    589         .iter()
    590         .filter_map(|(k, v)| v.as_str().map(|s| (k.as_str(), s.len())))
    591         .collect();
    592     string_fields.sort_by(|a, b| b.1.cmp(&a.1));
    593 
    594     if string_fields.is_empty() {
    595         return tool_input.clone();
    596     }
    597 
    598     let excess = serialized.len() - max_bytes;
    599     let suffix = "\n... (truncated)";
    600     let suffix_len = suffix.len();
    601     // Safety margin for JSON escaping differences and the added _truncated field
    602     let trim_target = excess + suffix_len * string_fields.len() + 64;
    603 
    604     // Distribute the trim proportionally among string fields
    605     let total_string_bytes: usize = string_fields.iter().map(|(_, len)| len).sum();
    606     let mut trim_amounts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
    607     for (key, len) in &string_fields {
    608         let share = (*len as f64 / total_string_bytes as f64 * trim_target as f64).ceil() as usize;
    609         trim_amounts.insert(key, share.min(*len));
    610     }
    611 
    612     let mut result = serde_json::Map::new();
    613     let mut did_truncate = false;
    614     for (key, val) in obj {
    615         if let Some(s) = val.as_str() {
    616             let trim = trim_amounts.get(key.as_str()).copied().unwrap_or(0);
    617             if trim > 0 && s.len() > suffix_len + trim {
    618                 let keep = s.len() - trim;
    619                 let cut = notedeck::abbrev::floor_char_boundary(s, keep);
    620                 let truncated = format!("{}{}", &s[..cut], suffix);
    621                 result.insert(key.clone(), Value::String(truncated));
    622                 did_truncate = true;
    623             } else {
    624                 result.insert(key.clone(), val.clone());
    625             }
    626         } else {
    627             result.insert(key.clone(), val.clone());
    628         }
    629     }
    630 
    631     if did_truncate {
    632         result.insert("_truncated".to_string(), Value::Bool(true));
    633     }
    634     Value::Object(result)
    635 }
    636 
    637 /// Does NOT participate in threading — permission events are ancillary.
    638 pub fn build_permission_request_event(
    639     perm_id: &uuid::Uuid,
    640     tool_name: &str,
    641     tool_input: &serde_json::Value,
    642     session_id: &str,
    643     secret_key: &[u8; 32],
    644 ) -> Result<BuiltEvent, EventBuildError> {
    645     // Truncate large string values so the event fits within relay size
    646     // limits after PNS wrapping.  The local UI keeps the full tool_input.
    647     let tool_input_for_event = truncate_tool_input(tool_input, MAX_TOOL_INPUT_BYTES);
    648 
    649     let content = serde_json::json!({
    650         "tool_name": tool_name,
    651         "tool_input": tool_input_for_event,
    652     })
    653     .to_string();
    654 
    655     let perm_id_str = perm_id.to_string();
    656 
    657     let mut builder = init_note_builder(AI_CONVERSATION_KIND, &content, Some(now_secs()));
    658 
    659     // Session identity
    660     builder = builder.start_tag().tag_str("d").tag_str(session_id);
    661 
    662     // Permission-specific tags
    663     builder = builder.start_tag().tag_str("perm-id").tag_str(&perm_id_str);
    664     builder = builder.start_tag().tag_str("tool-name").tag_str(tool_name);
    665     builder = builder
    666         .start_tag()
    667         .tag_str("role")
    668         .tag_str("permission_request");
    669     builder = builder
    670         .start_tag()
    671         .tag_str("source")
    672         .tag_str("notedeck-dave");
    673 
    674     // Discoverability
    675     builder = builder.start_tag().tag_str("t").tag_str("ai-conversation");
    676     builder = builder.start_tag().tag_str("t").tag_str("ai-permission");
    677 
    678     finalize_built_event(builder, secret_key, AI_CONVERSATION_KIND)
    679 }
    680 
    681 /// Build a kind-1988 permission response event.
    682 ///
    683 /// Published by remote clients (phone) to allow/deny a permission request.
    684 /// The desktop subscribes for these and routes them through the existing
    685 /// oneshot channel, racing with the local UI.
    686 ///
    687 /// Tags include `perm-id` (matching the request), `e` tag linking to the
    688 /// request event, and `t: ai-permission` for filtering.
    689 pub fn build_permission_response_event(
    690     perm_id: &uuid::Uuid,
    691     request_note_id: &[u8; 32],
    692     allowed: bool,
    693     message: Option<&str>,
    694     session_id: &str,
    695     secret_key: &[u8; 32],
    696 ) -> Result<BuiltEvent, EventBuildError> {
    697     let content = serde_json::json!({
    698         "decision": if allowed { "allow" } else { "deny" },
    699         "message": message.unwrap_or(""),
    700     })
    701     .to_string();
    702 
    703     let perm_id_str = perm_id.to_string();
    704 
    705     let mut builder = init_note_builder(AI_CONVERSATION_KIND, &content, Some(now_secs()));
    706 
    707     // Session identity
    708     builder = builder.start_tag().tag_str("d").tag_str(session_id);
    709 
    710     // Link to the request event
    711     builder = builder.start_tag().tag_str("e").tag_id(request_note_id);
    712 
    713     // Permission-specific tags
    714     builder = builder.start_tag().tag_str("perm-id").tag_str(&perm_id_str);
    715     builder = builder
    716         .start_tag()
    717         .tag_str("role")
    718         .tag_str("permission_response");
    719     builder = builder
    720         .start_tag()
    721         .tag_str("source")
    722         .tag_str("notedeck-dave");
    723 
    724     // Discoverability
    725     builder = builder.start_tag().tag_str("t").tag_str("ai-conversation");
    726     builder = builder.start_tag().tag_str("t").tag_str("ai-permission");
    727 
    728     finalize_built_event(builder, secret_key, AI_CONVERSATION_KIND)
    729 }
    730 
    731 /// Build a kind-31988 session state event (parameterized replaceable).
    732 ///
    733 /// Published on every status change so remote clients and startup restore
    734 /// can discover active sessions. nostrdb auto-replaces older versions
    735 /// with same (kind, pubkey, d-tag).
    736 #[allow(clippy::too_many_arguments)]
    737 pub fn build_session_state_event(
    738     event_session_id: &str,
    739     title: &str,
    740     custom_title: Option<&str>,
    741     cwd: &str,
    742     status: &str,
    743     indicator: Option<&str>,
    744     hostname: &str,
    745     home_dir: &str,
    746     backend: &str,
    747     permission_mode: &str,
    748     cli_session_id: Option<&str>,
    749     secret_key: &[u8; 32],
    750 ) -> Result<BuiltEvent, EventBuildError> {
    751     let mut builder = init_note_builder(AI_SESSION_STATE_KIND, "", Some(now_secs()));
    752 
    753     // Session identity (makes this a parameterized replaceable event)
    754     builder = builder.start_tag().tag_str("d").tag_str(event_session_id);
    755 
    756     // Session metadata as tags
    757     builder = builder.start_tag().tag_str("title").tag_str(title);
    758     if let Some(ct) = custom_title {
    759         builder = builder.start_tag().tag_str("custom_title").tag_str(ct);
    760     }
    761     builder = builder.start_tag().tag_str("cwd").tag_str(cwd);
    762     builder = builder.start_tag().tag_str("status").tag_str(status);
    763     if let Some(ind) = indicator {
    764         builder = builder.start_tag().tag_str("indicator").tag_str(ind);
    765     }
    766     builder = builder.start_tag().tag_str("hostname").tag_str(hostname);
    767     builder = builder.start_tag().tag_str("home_dir").tag_str(home_dir);
    768     builder = builder.start_tag().tag_str("backend").tag_str(backend);
    769     builder = builder
    770         .start_tag()
    771         .tag_str("permission-mode")
    772         .tag_str(permission_mode);
    773 
    774     // Real Claude CLI session ID for backend --resume.
    775     // Empty string means the backend hasn't started yet.
    776     // Absent (old events) means the d-tag itself is the CLI ID.
    777     builder = builder
    778         .start_tag()
    779         .tag_str("cli_session")
    780         .tag_str(cli_session_id.unwrap_or(""));
    781 
    782     // Discoverability
    783     builder = builder.start_tag().tag_str("t").tag_str("ai-session-state");
    784     builder = builder.start_tag().tag_str("t").tag_str("ai-conversation");
    785     builder = builder
    786         .start_tag()
    787         .tag_str("source")
    788         .tag_str("notedeck-dave");
    789 
    790     finalize_built_event(builder, secret_key, AI_SESSION_STATE_KIND)
    791 }
    792 
    793 /// Build a kind-31989 spawn command event.
    794 ///
    795 /// This is a fire-and-forget command that tells a remote host to create a new
    796 /// session. The target host discovers the command via its ndb subscription,
    797 /// creates the session locally, and publishes a kind-31988 state event.
    798 pub fn build_spawn_command_event(
    799     target_host: &str,
    800     cwd: &str,
    801     backend: &str,
    802     secret_key: &[u8; 32],
    803 ) -> Result<BuiltEvent, EventBuildError> {
    804     let command_id = uuid::Uuid::new_v4().to_string();
    805     let mut builder = init_note_builder(AI_SESSION_COMMAND_KIND, "", Some(now_secs()));
    806 
    807     builder = builder.start_tag().tag_str("d").tag_str(&command_id);
    808     builder = builder
    809         .start_tag()
    810         .tag_str("command")
    811         .tag_str("spawn_session");
    812     builder = builder
    813         .start_tag()
    814         .tag_str("target_host")
    815         .tag_str(target_host);
    816     builder = builder.start_tag().tag_str("cwd").tag_str(cwd);
    817     builder = builder.start_tag().tag_str("backend").tag_str(backend);
    818     builder = builder
    819         .start_tag()
    820         .tag_str("t")
    821         .tag_str("ai-session-command");
    822     builder = builder
    823         .start_tag()
    824         .tag_str("source")
    825         .tag_str("notedeck-dave");
    826 
    827     finalize_built_event(builder, secret_key, AI_SESSION_COMMAND_KIND)
    828 }
    829 
    830 /// Build a kind-1988 command event to set permission mode on a remote session.
    831 ///
    832 /// Published by remote observers to request a permission mode change on the host.
    833 /// The host subscribes for these and applies them via its local backend.
    834 pub fn build_set_permission_mode_event(
    835     mode: &str,
    836     session_id: &str,
    837     secret_key: &[u8; 32],
    838 ) -> Result<BuiltEvent, EventBuildError> {
    839     let content = serde_json::json!({
    840         "mode": mode,
    841     })
    842     .to_string();
    843 
    844     let mut builder = init_note_builder(AI_CONVERSATION_KIND, &content, Some(now_secs()));
    845 
    846     builder = builder.start_tag().tag_str("d").tag_str(session_id);
    847     builder = builder
    848         .start_tag()
    849         .tag_str("role")
    850         .tag_str("set_permission_mode");
    851     builder = builder
    852         .start_tag()
    853         .tag_str("source")
    854         .tag_str("notedeck-dave");
    855     builder = builder.start_tag().tag_str("t").tag_str("ai-conversation");
    856     builder = builder.start_tag().tag_str("t").tag_str("ai-command");
    857 
    858     finalize_built_event(builder, secret_key, AI_CONVERSATION_KIND)
    859 }
    860 
    861 #[cfg(test)]
    862 mod tests {
    863     use super::*;
    864 
    865     // Test secret key (32 bytes, not for real use)
    866     fn test_secret_key() -> [u8; 32] {
    867         let mut key = [0u8; 32];
    868         key[0] = 1; // non-zero so signing works
    869         key
    870     }
    871 
    872     #[test]
    873     fn test_build_user_text_event() {
    874         let line = JsonlLine::parse(
    875             r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"sess1","timestamp":"2026-02-09T20:43:35.675Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"user","content":"Human: hello world\n\n"}}"#,
    876         )
    877         .unwrap();
    878 
    879         let mut threading = ThreadingState::new();
    880         let events = build_events(&line, &mut threading, &test_secret_key()).unwrap();
    881 
    882         // 1 conversation event (1988) + 1 source-data event (1989)
    883         assert_eq!(events.len(), 2);
    884         assert_eq!(events[0].kind, AI_CONVERSATION_KIND);
    885         assert_eq!(events[1].kind, AI_SOURCE_DATA_KIND);
    886         assert!(threading.root_note_id.is_some());
    887         assert_eq!(threading.root_note_id, Some(events[0].note_id));
    888 
    889         // 1988 event has kind and tags but NO source-data
    890         let json = &events[0].note_json;
    891         assert!(json.contains("1988"));
    892         assert!(json.contains("source"));
    893         assert!(json.contains("claude-code"));
    894         assert!(json.contains("role"));
    895         assert!(json.contains("user"));
    896         assert!(!json.contains("source-data"));
    897 
    898         // 1989 event has source-data
    899         assert!(events[1].note_json.contains("source-data"));
    900     }
    901 
    902     #[test]
    903     fn test_build_assistant_text_event() {
    904         let line = JsonlLine::parse(
    905             r#"{"type":"assistant","uuid":"u2","parentUuid":"u1","sessionId":"sess1","timestamp":"2026-02-09T20:43:38.421Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"I can help with that."}]}}"#,
    906         )
    907         .unwrap();
    908 
    909         let mut threading = ThreadingState::new();
    910         // Simulate a prior event
    911         threading.root_note_id = Some([1u8; 32]);
    912         threading.last_note_id = Some([1u8; 32]);
    913 
    914         let events = build_events(&line, &mut threading, &test_secret_key()).unwrap();
    915         // 1 conversation (1988) + 1 source-data (1989)
    916         assert_eq!(events.len(), 2);
    917         assert_eq!(events[0].kind, AI_CONVERSATION_KIND);
    918         assert_eq!(events[1].kind, AI_SOURCE_DATA_KIND);
    919 
    920         let json = &events[0].note_json;
    921         assert!(json.contains("assistant"));
    922         assert!(json.contains("claude-opus-4-5-20251101")); // model tag
    923     }
    924 
    925     #[test]
    926     fn test_build_split_assistant_mixed_content() {
    927         let line = JsonlLine::parse(
    928             r#"{"type":"assistant","uuid":"u3","sessionId":"sess1","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"Let me check."},{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/tmp/test.rs"}}]}}"#,
    929         )
    930         .unwrap();
    931 
    932         let mut threading = ThreadingState::new();
    933         let events = build_events(&line, &mut threading, &test_secret_key()).unwrap();
    934 
    935         // 2 conversation events (1988) + 1 source-data (1989)
    936         assert_eq!(events.len(), 3);
    937         assert_eq!(events[0].kind, AI_CONVERSATION_KIND);
    938         assert_eq!(events[1].kind, AI_CONVERSATION_KIND);
    939         assert_eq!(events[2].kind, AI_SOURCE_DATA_KIND);
    940 
    941         // All should have unique note IDs
    942         assert_ne!(events[0].note_id, events[1].note_id);
    943         assert_ne!(events[0].note_id, events[2].note_id);
    944     }
    945 
    946     #[test]
    947     fn test_threading_chain() {
    948         let lines = vec![
    949             r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"s","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"user","content":"hello"}}"#,
    950             r#"{"type":"assistant","uuid":"u2","parentUuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:01Z","cwd":"/tmp","version":"2.0.64","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}"#,
    951             r#"{"type":"user","uuid":"u3","parentUuid":"u2","sessionId":"s","timestamp":"2026-02-09T20:00:02Z","cwd":"/tmp","version":"2.0.64","message":{"role":"user","content":"bye"}}"#,
    952         ];
    953 
    954         let mut threading = ThreadingState::new();
    955         let sk = test_secret_key();
    956         let mut all_events = vec![];
    957 
    958         for line_str in &lines {
    959             let line = JsonlLine::parse(line_str).unwrap();
    960             let events = build_events(&line, &mut threading, &sk).unwrap();
    961             all_events.extend(events);
    962         }
    963 
    964         // 3 lines × (1 conversation + 1 source-data) = 6 events
    965         assert_eq!(all_events.len(), 6);
    966 
    967         // Filter to only 1988 events for threading checks
    968         let conv_events: Vec<_> = all_events
    969             .iter()
    970             .filter(|e| e.kind == AI_CONVERSATION_KIND)
    971             .collect();
    972         assert_eq!(conv_events.len(), 3);
    973 
    974         // First event should be root (no e tags)
    975         // Subsequent events should reference root + previous
    976         assert!(!conv_events[0].note_json.contains("root"));
    977         assert!(conv_events[1].note_json.contains("root"));
    978         assert!(conv_events[1].note_json.contains("reply"));
    979         assert!(conv_events[2].note_json.contains("root"));
    980         assert!(conv_events[2].note_json.contains("reply"));
    981     }
    982 
    983     #[test]
    984     fn test_source_data_preserves_raw_json() {
    985         let line = JsonlLine::parse(
    986             r#"{"type":"user","uuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:00Z","cwd":"/Users/jb55/dev/notedeck","version":"2.0.64","message":{"role":"user","content":"check /Users/jb55/dev/notedeck/src/main.rs"}}"#,
    987         )
    988         .unwrap();
    989 
    990         let mut threading = ThreadingState::new();
    991         let events = build_events(&line, &mut threading, &test_secret_key()).unwrap();
    992 
    993         // 1988 event should NOT have source-data
    994         assert!(!events[0].note_json.contains("source-data"));
    995 
    996         // 1989 event should have source-data with raw paths preserved
    997         let sd_event = events
    998             .iter()
    999             .find(|e| e.kind == AI_SOURCE_DATA_KIND)
   1000             .unwrap();
   1001         assert!(sd_event.note_json.contains("source-data"));
   1002         assert!(sd_event.note_json.contains("/Users/jb55/dev/notedeck"));
   1003     }
   1004 
   1005     #[test]
   1006     fn test_queue_operation_event() {
   1007         let line = JsonlLine::parse(
   1008             r#"{"type":"queue-operation","operation":"dequeue","timestamp":"2026-02-09T20:43:35.669Z","sessionId":"sess1"}"#,
   1009         )
   1010         .unwrap();
   1011 
   1012         let mut threading = ThreadingState::new();
   1013         let events = build_events(&line, &mut threading, &test_secret_key()).unwrap();
   1014         // 1 conversation (1988) + 1 source-data (1989)
   1015         assert_eq!(events.len(), 2);
   1016 
   1017         let json = &events[0].note_json;
   1018         assert!(json.contains("queue-operation"));
   1019     }
   1020 
   1021     #[test]
   1022     fn test_seq_counter_increments() {
   1023         let lines = vec![
   1024             r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"s","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"user","content":"hello"}}"#,
   1025             r#"{"type":"assistant","uuid":"u2","parentUuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:01Z","cwd":"/tmp","version":"2.0.64","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]}}"#,
   1026         ];
   1027 
   1028         let mut threading = ThreadingState::new();
   1029         let sk = test_secret_key();
   1030 
   1031         assert_eq!(threading.seq(), 0);
   1032 
   1033         let line = JsonlLine::parse(lines[0]).unwrap();
   1034         let events = build_events(&line, &mut threading, &sk).unwrap();
   1035         // 1 conversation + 1 source-data
   1036         assert_eq!(events.len(), 2);
   1037         assert_eq!(threading.seq(), 1);
   1038         // First 1988 event should have seq=0
   1039         assert!(events[0].note_json.contains(r#""seq","0"#));
   1040         // 1989 event should also have seq=0 (matches its 1988 event)
   1041         assert!(events[1].note_json.contains(r#""seq","0"#));
   1042 
   1043         let line = JsonlLine::parse(lines[1]).unwrap();
   1044         let events = build_events(&line, &mut threading, &sk).unwrap();
   1045         assert_eq!(events.len(), 2);
   1046         assert_eq!(threading.seq(), 2);
   1047         // Second 1988 event should have seq=1
   1048         assert!(events[0].note_json.contains(r#""seq","1"#));
   1049     }
   1050 
   1051     #[test]
   1052     fn test_split_tags_and_source_data() {
   1053         let line = JsonlLine::parse(
   1054             r#"{"type":"assistant","uuid":"u3","sessionId":"sess1","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"Let me check."},{"type":"tool_use","id":"t1","name":"Read","input":{"file_path":"/tmp/test.rs"}}]}}"#,
   1055         )
   1056         .unwrap();
   1057 
   1058         let mut threading = ThreadingState::new();
   1059         let events = build_events(&line, &mut threading, &test_secret_key()).unwrap();
   1060         // 2 conversation (1988) + 1 source-data (1989)
   1061         assert_eq!(events.len(), 3);
   1062 
   1063         // First event (text): split 0/2, NO source-data (moved to 1989)
   1064         assert!(events[0].note_json.contains(r#""split","0/2"#));
   1065         assert!(!events[0].note_json.contains("source-data"));
   1066 
   1067         // Second event (tool_call): split 1/2, NO source-data, has tool-id
   1068         assert!(events[1].note_json.contains(r#""split","1/2"#));
   1069         assert!(!events[1].note_json.contains("source-data"));
   1070         assert!(events[1].note_json.contains(r#""tool-id","t1"#));
   1071 
   1072         // Third event (1989): has source-data
   1073         assert_eq!(events[2].kind, AI_SOURCE_DATA_KIND);
   1074         assert!(events[2].note_json.contains("source-data"));
   1075     }
   1076 
   1077     #[test]
   1078     fn test_cwd_tag() {
   1079         let line = JsonlLine::parse(
   1080             r#"{"type":"user","uuid":"u1","sessionId":"s","timestamp":"2026-02-09T20:00:00Z","cwd":"/Users/jb55/dev/notedeck","version":"2.0.64","message":{"role":"user","content":"hello"}}"#,
   1081         )
   1082         .unwrap();
   1083 
   1084         let mut threading = ThreadingState::new();
   1085         let events = build_events(&line, &mut threading, &test_secret_key()).unwrap();
   1086 
   1087         assert!(events[0]
   1088             .note_json
   1089             .contains(r#""cwd","/Users/jb55/dev/notedeck"#));
   1090     }
   1091 
   1092     #[test]
   1093     fn test_tool_result_has_tool_id() {
   1094         let line = JsonlLine::parse(
   1095             r#"{"type":"user","uuid":"u4","parentUuid":"u3","cwd":"/tmp","sessionId":"s","version":"2.0.64","timestamp":"2026-02-09T20:00:03Z","message":{"role":"user","content":[{"tool_use_id":"toolu_abc","type":"tool_result","content":"file contents"}]}}"#,
   1096         )
   1097         .unwrap();
   1098 
   1099         let mut threading = ThreadingState::new();
   1100         let events = build_events(&line, &mut threading, &test_secret_key()).unwrap();
   1101         // 1 conversation + 1 source-data
   1102         assert_eq!(events.len(), 2);
   1103         assert!(events[0].note_json.contains(r#""tool-id","toolu_abc"#));
   1104     }
   1105 
   1106     #[tokio::test]
   1107     async fn test_full_roundtrip() {
   1108         use crate::session_reconstructor;
   1109         use nostrdb::{Config, IngestMetadata, Ndb, Transaction};
   1110         use serde_json::Value;
   1111         use tempfile::TempDir;
   1112 
   1113         // Sample JSONL lines covering different message types
   1114         let jsonl_lines = vec![
   1115             r#"{"type":"queue-operation","operation":"dequeue","timestamp":"2026-02-09T20:00:00Z","sessionId":"roundtrip-test"}"#,
   1116             r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"roundtrip-test","timestamp":"2026-02-09T20:00:01Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"user","content":"Human: hello world\n\n"}}"#,
   1117             r#"{"type":"assistant","uuid":"u2","parentUuid":"u1","sessionId":"roundtrip-test","timestamp":"2026-02-09T20:00:02Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"Let me check that file."},{"type":"tool_use","id":"toolu_1","name":"Read","input":{"file_path":"/tmp/project/main.rs"}}]}}"#,
   1118             r#"{"type":"user","uuid":"u3","parentUuid":"u2","sessionId":"roundtrip-test","timestamp":"2026-02-09T20:00:03Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"user","content":[{"tool_use_id":"toolu_1","type":"tool_result","content":"fn main() {}"}]}}"#,
   1119             r#"{"type":"assistant","uuid":"u4","parentUuid":"u3","sessionId":"roundtrip-test","timestamp":"2026-02-09T20:00:04Z","cwd":"/tmp/project","version":"2.0.64","message":{"role":"assistant","model":"claude-opus-4-5-20251101","content":[{"type":"text","text":"That's a simple main function."}]}}"#,
   1120         ];
   1121 
   1122         // Set up ndb
   1123         let tmp_dir = TempDir::new().unwrap();
   1124         let ndb = Ndb::new(tmp_dir.path().to_str().unwrap(), &Config::new()).unwrap();
   1125 
   1126         // Build and ingest events one at a time, waiting for each
   1127         let sk = test_secret_key();
   1128         let mut threading = ThreadingState::new();
   1129         let mut total_events = 0;
   1130 
   1131         let filter = nostrdb::Filter::new()
   1132             .kinds([AI_CONVERSATION_KIND as u64, AI_SOURCE_DATA_KIND as u64])
   1133             .build();
   1134 
   1135         for line_str in &jsonl_lines {
   1136             let line = JsonlLine::parse(line_str).unwrap();
   1137             let events = build_events(&line, &mut threading, &sk).unwrap();
   1138             for event in &events {
   1139                 let sub_id = ndb.subscribe(&[filter.clone()]).unwrap();
   1140                 ndb.process_event_with(&event.to_event_json(), IngestMetadata::new().client(true))
   1141                     .expect("ingest failed");
   1142                 let _keys = ndb.wait_for_notes(sub_id, 1).await.unwrap();
   1143                 total_events += 1;
   1144             }
   1145         }
   1146 
   1147         // Each JSONL line produces N conversation events + 1 source-data event.
   1148         // Line 1 (queue-op): 1 conv + 1 sd = 2
   1149         // Line 2 (user): 1 conv + 1 sd = 2
   1150         // Line 3 (assistant split): 2 conv + 1 sd = 3
   1151         // Line 4 (user tool_result): 1 conv + 1 sd = 2
   1152         // Line 5 (assistant): 1 conv + 1 sd = 2
   1153         // Total: 11
   1154         assert_eq!(total_events, 11);
   1155 
   1156         // Reconstruct JSONL from ndb
   1157         let txn = Transaction::new(&ndb).unwrap();
   1158         let reconstructed =
   1159             session_reconstructor::reconstruct_jsonl_lines(&ndb, &txn, "roundtrip-test").unwrap();
   1160 
   1161         // Should get back one JSONL line per original line
   1162         assert_eq!(
   1163             reconstructed.len(),
   1164             jsonl_lines.len(),
   1165             "expected {} lines, got {}",
   1166             jsonl_lines.len(),
   1167             reconstructed.len()
   1168         );
   1169 
   1170         // Compare each line as serde_json::Value for order-independent equality
   1171         for (i, (original, reconstructed)) in
   1172             jsonl_lines.iter().zip(reconstructed.iter()).enumerate()
   1173         {
   1174             let orig_val: Value = serde_json::from_str(original).unwrap();
   1175             let recon_val: Value = serde_json::from_str(reconstructed).unwrap();
   1176             assert_eq!(
   1177                 orig_val, recon_val,
   1178                 "line {} mismatch.\noriginal:      {}\nreconstructed: {}",
   1179                 i, original, reconstructed
   1180             );
   1181         }
   1182     }
   1183 
   1184     #[test]
   1185     fn test_file_history_snapshot_inherits_context() {
   1186         // file-history-snapshot lines lack sessionId and top-level timestamp.
   1187         // They should inherit session_id from a prior line and get timestamp
   1188         // from snapshot.timestamp.
   1189         let lines = vec![
   1190             r#"{"type":"user","uuid":"u1","parentUuid":null,"sessionId":"ctx-test","timestamp":"2026-02-09T20:00:00Z","cwd":"/tmp","version":"2.0.64","message":{"role":"user","content":"hello"}}"#,
   1191             r#"{"type":"file-history-snapshot","messageId":"abc","snapshot":{"messageId":"abc","trackedFileBackups":{},"timestamp":"2026-02-11T01:29:31.555Z"},"isSnapshotUpdate":false}"#,
   1192         ];
   1193 
   1194         let mut threading = ThreadingState::new();
   1195         let sk = test_secret_key();
   1196 
   1197         // First line sets context
   1198         let line = JsonlLine::parse(lines[0]).unwrap();
   1199         let events = build_events(&line, &mut threading, &sk).unwrap();
   1200         assert!(events[0].note_json.contains(r#""d","ctx-test"#));
   1201 
   1202         // Second line (file-history-snapshot) should inherit session_id
   1203         let line = JsonlLine::parse(lines[1]).unwrap();
   1204         assert!(line.session_id().is_none()); // no top-level sessionId
   1205         let events = build_events(&line, &mut threading, &sk).unwrap();
   1206 
   1207         // 1988 event should have inherited d tag
   1208         assert!(events[0].note_json.contains(r#""d","ctx-test"#));
   1209         // Should have snapshot timestamp (1770773371), not the user's
   1210         assert!(events[0].note_json.contains(r#""created_at":1770773371"#));
   1211     }
   1212 
   1213     #[test]
   1214     fn test_build_permission_request_event() {
   1215         let perm_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
   1216         let tool_input = serde_json::json!({"command": "rm -rf /tmp/test"});
   1217         let sk = test_secret_key();
   1218 
   1219         let event =
   1220             build_permission_request_event(&perm_id, "Bash", &tool_input, "sess-perm-test", &sk)
   1221                 .unwrap();
   1222 
   1223         assert_eq!(event.kind, AI_CONVERSATION_KIND);
   1224 
   1225         let json = &event.note_json;
   1226         // Has permission-specific tags
   1227         assert!(json.contains(r#""perm-id","550e8400-e29b-41d4-a716-446655440000"#));
   1228         assert!(json.contains(r#""tool-name","Bash"#));
   1229         assert!(json.contains(r#""role","permission_request"#));
   1230         // Has session identity
   1231         assert!(json.contains(r#""d","sess-perm-test"#));
   1232         // Has discoverability tags
   1233         assert!(json.contains(r#""t","ai-conversation"#));
   1234         assert!(json.contains(r#""t","ai-permission"#));
   1235         // Content has tool info
   1236         assert!(json.contains("rm -rf"));
   1237     }
   1238 
   1239     #[test]
   1240     fn test_truncate_tool_input_small() {
   1241         // Small input should pass through unchanged
   1242         let input = serde_json::json!({"command": "ls -la"});
   1243         let result = truncate_tool_input(&input, 1000);
   1244         assert_eq!(input, result);
   1245         assert!(result.get("_truncated").is_none());
   1246     }
   1247 
   1248     #[test]
   1249     fn test_truncate_tool_input_large_edit() {
   1250         // Large edit should be truncated
   1251         let big_old = "x".repeat(30_000);
   1252         let big_new = "y".repeat(30_000);
   1253         let input = serde_json::json!({
   1254             "file_path": "/some/file.rs",
   1255             "old_string": big_old,
   1256             "new_string": big_new,
   1257         });
   1258         let result = truncate_tool_input(&input, 40_000);
   1259 
   1260         // Should fit within budget
   1261         let serialized = serde_json::to_string(&result).unwrap();
   1262         assert!(serialized.len() <= 40_000, "got {} bytes", serialized.len());
   1263 
   1264         // Should be marked as truncated
   1265         assert_eq!(
   1266             result.get("_truncated").and_then(|v| v.as_bool()),
   1267             Some(true)
   1268         );
   1269 
   1270         // file_path should be preserved
   1271         assert_eq!(
   1272             result.get("file_path").and_then(|v| v.as_str()),
   1273             Some("/some/file.rs")
   1274         );
   1275 
   1276         // Truncated fields should end with the suffix
   1277         let old = result.get("old_string").and_then(|v| v.as_str()).unwrap();
   1278         assert!(old.ends_with("... (truncated)"));
   1279     }
   1280 
   1281     #[test]
   1282     fn test_truncate_tool_input_utf8_boundary() {
   1283         // Multibyte chars should not be split
   1284         let big = "\u{1F600}".repeat(10_000); // 4-byte emoji repeated
   1285         let input = serde_json::json!({"content": big});
   1286         let result = truncate_tool_input(&input, 1000);
   1287 
   1288         let content = result.get("content").and_then(|v| v.as_str()).unwrap();
   1289         // Should be valid UTF-8 (this would panic if not)
   1290         assert!(content.ends_with("... (truncated)"));
   1291     }
   1292 
   1293     #[test]
   1294     fn test_build_permission_response_event() {
   1295         let perm_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
   1296         let request_note_id = [42u8; 32];
   1297         let sk = test_secret_key();
   1298 
   1299         // Test allow response
   1300         let event = build_permission_response_event(
   1301             &perm_id,
   1302             &request_note_id,
   1303             true,
   1304             Some("looks safe"),
   1305             "sess-perm-test",
   1306             &sk,
   1307         )
   1308         .unwrap();
   1309 
   1310         assert_eq!(event.kind, AI_CONVERSATION_KIND);
   1311 
   1312         let json = &event.note_json;
   1313         assert!(json.contains(r#""perm-id","550e8400-e29b-41d4-a716-446655440000"#));
   1314         assert!(json.contains(r#""role","permission_response"#));
   1315         assert!(json.contains(r#""d","sess-perm-test"#));
   1316         assert!(json.contains("allow"));
   1317         assert!(json.contains("looks safe"));
   1318         // Has e tag linking to request
   1319         assert!(json.contains(r#""e""#));
   1320     }
   1321 
   1322     #[test]
   1323     fn test_permission_response_deny() {
   1324         let perm_id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
   1325         let request_note_id = [42u8; 32];
   1326         let sk = test_secret_key();
   1327 
   1328         let event = build_permission_response_event(
   1329             &perm_id,
   1330             &request_note_id,
   1331             false,
   1332             Some("too dangerous"),
   1333             "sess-perm-test",
   1334             &sk,
   1335         )
   1336         .unwrap();
   1337 
   1338         let json = &event.note_json;
   1339         assert!(json.contains("deny"));
   1340         assert!(json.contains("too dangerous"));
   1341     }
   1342 
   1343     #[test]
   1344     fn test_build_session_state_event() {
   1345         let sk = test_secret_key();
   1346 
   1347         let event = build_session_state_event(
   1348             "sess-state-test",
   1349             "Fix the login bug",
   1350             Some("My Custom Title"),
   1351             "/tmp/project",
   1352             "working",
   1353             Some("needs_input"),
   1354             "my-laptop",
   1355             "/home/testuser",
   1356             "claude",
   1357             "plan",
   1358             None,
   1359             &sk,
   1360         )
   1361         .unwrap();
   1362 
   1363         assert_eq!(event.kind, AI_SESSION_STATE_KIND);
   1364 
   1365         let json = &event.note_json;
   1366         // Kind 31988 (parameterized replaceable)
   1367         assert!(json.contains("31988"));
   1368         // Has d tag for replacement
   1369         assert!(json.contains(r#""d","sess-state-test"#));
   1370         // Has discoverability tags
   1371         assert!(json.contains(r#""t","ai-session-state"#));
   1372         assert!(json.contains(r#""t","ai-conversation"#));
   1373         // Content has state fields
   1374         assert!(json.contains("Fix the login bug"));
   1375         assert!(json.contains("working"));
   1376         assert!(json.contains("/tmp/project"));
   1377         assert!(json.contains(r#""hostname","my-laptop"#));
   1378         assert!(json.contains(r#""backend","claude"#));
   1379         assert!(json.contains(r#""permission-mode","plan"#));
   1380     }
   1381 
   1382     #[test]
   1383     fn test_wrap_pns() {
   1384         let sk = test_secret_key();
   1385         let pns_keys = enostr::pns::derive_pns_keys(&sk);
   1386 
   1387         let inner = r#"{"kind":1988,"content":"hello","tags":[],"created_at":0,"pubkey":"abc","id":"def","sig":"ghi"}"#;
   1388         let wrapped = wrap_pns(inner, &pns_keys).unwrap();
   1389 
   1390         // Outer event should be kind 1080
   1391         assert!(wrapped.contains("1080"));
   1392         // Should NOT contain the plaintext inner content
   1393         assert!(!wrapped.contains("hello"));
   1394         // Should be valid JSON
   1395         assert!(serde_json::from_str::<serde_json::Value>(&wrapped).is_ok());
   1396     }
   1397 }