Skip to main content

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;