swink_agent_memory/entry.rs
1//! Rich session entry types for persisting non-message events.
2//!
3//! [`SessionEntry`] is a discriminated union of all entry types that can be
4//! stored in a session JSONL file. Only the [`SessionEntry::Message`] variant
5//! is sent to the LLM — all other variants are audit/display metadata.
6
7use serde::{Deserialize, Serialize};
8use swink_agent::{LlmMessage, ModelSpec};
9
10/// A single entry in a persisted session.
11///
12/// Serialized with an adjacently-tagged representation: `{"entry_type": "...", "data": {...}}`.
13/// Old-format lines (raw `LlmMessage` without `entry_type`) are deserialized as
14/// [`SessionEntry::Message`] via a custom fallback in [`SessionEntry::parse`].
15#[non_exhaustive]
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(tag = "entry_type", content = "data", rename_all = "snake_case")]
18pub enum SessionEntry {
19 /// An LLM message (user, assistant, tool result). The only variant sent to the LLM.
20 Message(LlmMessage),
21
22 /// Records a model switch during the session.
23 ModelChange {
24 from: ModelSpec,
25 to: ModelSpec,
26 timestamp: u64,
27 },
28
29 /// Records a thinking level change.
30 ThinkingLevelChange {
31 from: String,
32 to: String,
33 timestamp: u64,
34 },
35
36 /// Records a context compaction event.
37 Compaction {
38 dropped_count: usize,
39 tokens_before: usize,
40 tokens_after: usize,
41 timestamp: u64,
42 },
43
44 /// A user bookmark/annotation on a specific message.
45 Label {
46 text: String,
47 message_index: usize,
48 timestamp: u64,
49 },
50
51 /// Arbitrary structured data for extensibility.
52 Custom {
53 type_name: String,
54 data: serde_json::Value,
55 timestamp: u64,
56 },
57}
58
59impl SessionEntry {
60 /// Parse a JSONL line into a `SessionEntry`.
61 ///
62 /// If the line has an `entry_type` field, it is deserialized as a tagged enum.
63 /// Otherwise, it is treated as a raw `LlmMessage` (backward compatibility).
64 pub fn parse(line: &str) -> Result<Self, serde_json::Error> {
65 // Try tagged format first
66 let value: serde_json::Value = serde_json::from_str(line)?;
67 if value.get("entry_type").is_some() {
68 return serde_json::from_value(value);
69 }
70 // Fallback: old-format raw LlmMessage
71 serde_json::from_value::<LlmMessage>(value).map(SessionEntry::Message)
72 }
73
74 /// Returns the contained `LlmMessage` if this is a `Message` variant.
75 pub const fn as_message(&self) -> Option<&LlmMessage> {
76 match self {
77 Self::Message(msg) => Some(msg),
78 _ => None,
79 }
80 }
81
82 /// Extract only `Message` entries from a slice, returning the `LlmMessage` values.
83 pub fn messages(entries: &[Self]) -> Vec<&LlmMessage> {
84 entries.iter().filter_map(Self::as_message).collect()
85 }
86
87 /// Returns the serde discriminator string for this entry variant.
88 ///
89 /// Matches the `rename_all = "snake_case"` representation used in serialization.
90 pub const fn entry_type_name(&self) -> &'static str {
91 match self {
92 Self::Message(_) => "message",
93 Self::ModelChange { .. } => "model_change",
94 Self::ThinkingLevelChange { .. } => "thinking_level_change",
95 Self::Compaction { .. } => "compaction",
96 Self::Label { .. } => "label",
97 Self::Custom { .. } => "custom",
98 }
99 }
100
101 /// Returns the timestamp of this entry, if it has one.
102 ///
103 /// `Message` entries derive their timestamp from the inner `LlmMessage`.
104 pub const fn timestamp(&self) -> Option<u64> {
105 match self {
106 // `LlmMessage` is `#[non_exhaustive]`: a variant this build
107 // doesn't recognise carries no known timestamp field, so report
108 // `None` rather than guessing at one.
109 Self::Message(msg) => match msg {
110 LlmMessage::User(m) => Some(m.timestamp),
111 LlmMessage::Assistant(m) => Some(m.timestamp),
112 LlmMessage::ToolResult(m) => Some(m.timestamp),
113 _ => None,
114 },
115 Self::ModelChange { timestamp, .. }
116 | Self::ThinkingLevelChange { timestamp, .. }
117 | Self::Compaction { timestamp, .. }
118 | Self::Label { timestamp, .. }
119 | Self::Custom { timestamp, .. } => Some(*timestamp),
120 }
121 }
122}
123
124const _: () = {
125 const fn assert_send_sync<T: Send + Sync>() {}
126 assert_send_sync::<SessionEntry>();
127};
128
129#[cfg(test)]
130#[path = "entry_tests.rs"]
131mod tests;