nerve_ipc/message.rs
1//! V1 message-level schema for NERVE.
2//!
3//! Each NERVE frame carries a binary header (see `frame.rs`) followed by a
4//! JSON payload defined here. JSON is used in V1 for debuggability and for
5//! easy implementation from JavaScript.
6//!
7//! # Message directions
8//!
9//! | `MessageType` | Wire byte | Direction |
10//! |-----------------|-----------|-------------------------------|
11//! | Ping | 0x01 | bidirectional |
12//! | `SearchQuery` | 0x02 | browser extension → AI daemon |
13//! | `SearchResult` | 0x03 | AI daemon → browser extension |
14//! | `AiToken` | 0x04 | AI daemon → browser extension |
15//! | Cancel | 0x05 | browser extension → AI daemon |
16//!
17//! # Payload size guidance
18//!
19//! The NERVE framing layer enforces a hard limit of 1 MiB per frame.
20//! `SearchQuery` context fields (`extract`, `selection`) should contain
21//! bounded, pre-processed excerpts — not raw DOM HTML. Browser-side
22//! extraction is responsible for keeping these fields small (target < 4 KiB).
23//!
24//! # Versioning
25//!
26//! `SearchQuery` carries a `v` field (currently always `1`). Implementations
27//! MUST reject payloads with `v != 1` rather than silently treating them as V1.
28
29use serde::{Deserialize, Serialize};
30
31/// The only supported message schema version.
32pub const MESSAGE_VERSION: u8 = 1;
33
34/// Maximum number of search results per response.
35pub const MAX_RESULTS: u32 = 100;
36
37// ─── Error ───────────────────────────────────────────────────────────────────
38
39/// Errors that can occur when encoding or decoding a NERVE message payload.
40#[non_exhaustive]
41#[derive(Debug)]
42pub enum MessageError {
43 /// The JSON payload could not be serialized or deserialized.
44 Json(serde_json::Error),
45 /// The `v` field in the payload does not equal [`MESSAGE_VERSION`].
46 UnsupportedVersion(u8),
47 /// The `query` field is an empty string.
48 EmptyQuery,
49 /// The `max_results` field is outside `1..=MAX_RESULTS`.
50 MaxResultsOutOfRange(u32),
51}
52
53impl std::fmt::Display for MessageError {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 match self {
56 MessageError::Json(e) => write!(f, "JSON error: {e}"),
57 MessageError::UnsupportedVersion(v) => {
58 write!(
59 f,
60 "unsupported message version: {v} (expected {MESSAGE_VERSION})"
61 )
62 }
63 MessageError::EmptyQuery => write!(f, "query field must not be empty"),
64 MessageError::MaxResultsOutOfRange(n) => {
65 write!(f, "max_results {n} is out of range (1..={MAX_RESULTS})")
66 }
67 }
68 }
69}
70
71impl std::error::Error for MessageError {
72 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
73 match self {
74 MessageError::Json(e) => Some(e),
75 _ => None,
76 }
77 }
78}
79
80impl From<serde_json::Error> for MessageError {
81 fn from(e: serde_json::Error) -> Self {
82 MessageError::Json(e)
83 }
84}
85
86// ─── SearchQuery (0x02) ──────────────────────────────────────────────────────
87
88/// Page context sent alongside a search query.
89///
90/// `url` and `title` are always present. `selection` is the user's highlighted
91/// text (if any). `extract` is a bounded text excerpt from the page body
92/// (target: < 4 KiB; browser-side extraction is responsible for truncation).
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct SearchContext {
95 pub url: String,
96 pub title: String,
97 #[serde(skip_serializing_if = "Option::is_none")]
98 pub selection: Option<String>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub extract: Option<String>,
101}
102
103/// Options controlling how the daemon handles a search query.
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct SearchOptions {
106 /// Whether to perform a web search (vs. local-only inference).
107 pub search: bool,
108 /// Maximum number of results to return (1..=`MAX_RESULTS`).
109 pub max_results: u32,
110}
111
112impl Default for SearchOptions {
113 fn default() -> Self {
114 Self {
115 search: true,
116 max_results: 10,
117 }
118 }
119}
120
121/// Payload for `MessageType::SearchQuery` (0x02).
122///
123/// Direction: browser extension → AI daemon.
124///
125/// JSON schema:
126/// ```json
127/// {
128/// "v": 1,
129/// "query": "rust async io",
130/// "context": {
131/// "url": "https://example.com/page",
132/// "title": "Example Page",
133/// "selection": "optional highlighted text",
134/// "extract": "optional page body excerpt"
135/// },
136/// "opts": {
137/// "search": true,
138/// "max_results": 10
139/// }
140/// }
141/// ```
142#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
143pub struct SearchQuery {
144 /// Schema version — must be `1`.
145 pub v: u8,
146 /// The user's search query (non-empty).
147 pub query: String,
148 pub context: SearchContext,
149 pub opts: SearchOptions,
150}
151
152impl SearchQuery {
153 /// Validates field constraints after deserialization.
154 ///
155 /// # Errors
156 ///
157 /// | Condition | Error |
158 /// |-----------|-------|
159 /// | `v != 1` | [`MessageError::UnsupportedVersion`] |
160 /// | `query` is empty | [`MessageError::EmptyQuery`] |
161 /// | `max_results == 0` or `> MAX_RESULTS` | [`MessageError::MaxResultsOutOfRange`] |
162 pub fn validate(&self) -> Result<(), MessageError> {
163 if self.v != MESSAGE_VERSION {
164 return Err(MessageError::UnsupportedVersion(self.v));
165 }
166 if self.query.is_empty() {
167 return Err(MessageError::EmptyQuery);
168 }
169 if self.opts.max_results == 0 || self.opts.max_results > MAX_RESULTS {
170 return Err(MessageError::MaxResultsOutOfRange(self.opts.max_results));
171 }
172 Ok(())
173 }
174}
175
176// ─── SearchResult (0x03) ─────────────────────────────────────────────────────
177
178/// A single ranked search result.
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
180pub struct SearchResultItem {
181 pub url: String,
182 pub title: String,
183 pub snippet: String,
184 /// Relevance score (higher is more relevant).
185 pub score: f32,
186}
187
188/// Payload for `MessageType::SearchResult` (0x03).
189///
190/// Direction: AI daemon → browser extension.
191///
192/// The NERVE frame's `request_id` links this response to the originating
193/// `SearchQuery`. This is a complete (non-streaming) response; the FINAL
194/// flag is set on its frame.
195///
196/// JSON schema:
197/// ```json
198/// {
199/// "results": [
200/// { "url": "…", "title": "…", "snippet": "…", "score": 0.92 }
201/// ],
202/// "took_ms": 34
203/// }
204/// ```
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct SearchResult {
207 pub results: Vec<SearchResultItem>,
208 /// Wall-clock time for the search in milliseconds.
209 pub took_ms: u64,
210}
211
212// ─── AiToken (0x04) ──────────────────────────────────────────────────────────
213
214/// Payload for `MessageType::AiToken` (0x04).
215///
216/// Direction: AI daemon → browser extension.
217///
218/// Tokens are streamed using the NERVE STREAM / FINAL flags:
219///
220/// ```text
221/// AiToken + STREAM
222/// AiToken + STREAM
223/// AiToken + FINAL ← last token for this request_id
224/// ```
225///
226/// The NERVE `request_id` identifies which inference stream this token belongs
227/// to. Do not implement a second streaming protocol inside the payload.
228///
229/// JSON schema:
230/// ```json
231/// { "t": "Hello" }
232/// ```
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234pub struct AiToken {
235 /// The token text (UTF-8). May be empty for the FINAL sentinel.
236 pub t: String,
237}
238
239// ─── Cancel (0x05) ───────────────────────────────────────────────────────────
240//
241// Cancel carries an empty payload. The target request is identified solely
242// by the `request_id` in the NERVE frame header — no payload struct is needed.
243// Encode with `encode(MessageType::Cancel, FrameFlags::FINAL, request_id, &[])`.
244
245// ─── Codec helpers ───────────────────────────────────────────────────────────
246
247/// Serialize a message struct to a JSON byte vector for use as a NERVE payload.
248///
249/// # Errors
250///
251/// Returns `Err` if `T`'s `Serialize` impl fails; for all types defined in this
252/// crate this is infallible.
253///
254/// # Examples
255///
256/// ```
257/// use nerve_ipc::message::{AiToken, encode_message};
258///
259/// let payload = encode_message(&AiToken { t: "Hello".into() }).unwrap();
260/// assert!(!payload.is_empty());
261/// ```
262pub fn encode_message<T: Serialize>(msg: &T) -> Result<Vec<u8>, serde_json::Error> {
263 serde_json::to_vec(msg)
264}
265
266/// Deserialize a NERVE payload into a message struct.
267///
268/// # Errors
269///
270/// Returns `Err` if `payload` is not valid JSON for type `T`.
271///
272/// # Examples
273///
274/// ```
275/// use nerve_ipc::message::{AiToken, encode_message, decode_message};
276///
277/// let payload = encode_message(&AiToken { t: "Hello".into() }).unwrap();
278/// let token: AiToken = decode_message(&payload).unwrap();
279/// assert_eq!(token.t, "Hello");
280/// ```
281pub fn decode_message<T: for<'de> Deserialize<'de>>(
282 payload: &[u8],
283) -> Result<T, serde_json::Error> {
284 serde_json::from_slice(payload)
285}
286
287/// Deserialize and validate a `SearchQuery` payload.
288///
289/// # Errors
290///
291/// | Condition | Error |
292/// |-----------|-------|
293/// | Invalid JSON | [`MessageError::Json`] |
294/// | `v != 1` | [`MessageError::UnsupportedVersion`] |
295/// | `query` is empty | [`MessageError::EmptyQuery`] |
296/// | `max_results` out of range | [`MessageError::MaxResultsOutOfRange`] |
297pub fn decode_search_query(payload: &[u8]) -> Result<SearchQuery, MessageError> {
298 let q: SearchQuery = serde_json::from_slice(payload)?;
299 q.validate()?;
300 Ok(q)
301}