Skip to main content

rig_memory/
meta.rs

1//! What a session says about itself beside its logs, kept as a file of its
2//! store: what a listing shows of it ([`SessionMeta`], in [`META`]).
3//!
4//! The file changes only by fields that a reader not knowing them skips and
5//! a reader missing them takes as empty.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::io;
9
10use rig_core::completion::Message;
11use rig_core::conversation::EntryRef;
12use rig_core::message::{AssistantContent, UserContent};
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use crate::{Files, Session, main_order};
17use rig_core::now_ms;
18
19/// The file of a session's store that holds its [`SessionMeta`].
20pub const META: &str = "meta.json";
21
22/// Most characters of a title made by [`title_of`].
23const TITLE_CHARS: usize = 60;
24
25/// What a listing shows of a session, kept in its store: what it is
26/// about, the labels plugins give it, and when it last changed. Its logs
27/// hold the same facts; this is the cache a listing reads without folding
28/// them. Fields a reader does not know are skipped, and missing ones are
29/// empty.
30#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(default)]
32pub struct SessionMeta {
33    /// What the session is about: the start of the first message the user
34    /// typed ([`title_of`]); empty until a turn ended.
35    pub title: String,
36    /// Plugins' labels by tag, such as a goal or what it cost.
37    pub labels: BTreeMap<String, String>,
38    /// When it last changed, in milliseconds since the Unix epoch.
39    pub updated: u64,
40    /// The main agent's model, `vendor/model`; empty when unknown.
41    pub model: String,
42    /// How many messages the main agent's active line holds.
43    pub messages: usize,
44    /// Plugins' listed values by key, each copied here whenever it changes,
45    /// so a listing shows and filters by them without opening a log; kept
46    /// small.
47    #[serde(rename = "data", skip_serializing_if = "BTreeMap::is_empty")]
48    pub listed: BTreeMap<String, Value>,
49}
50
51impl SessionMeta {
52    /// The listing kept in `files`, if it has one that reads.
53    pub fn read(files: &(impl Files + ?Sized)) -> Option<Self> {
54        serde_json::from_slice(&files.read_file(META).ok()?).ok()
55    }
56
57    /// Keeps it as the listing in `files`.
58    ///
59    /// # Errors
60    ///
61    /// When `files` cannot write it.
62    pub fn write(&self, files: &(impl Files + ?Sized)) -> io::Result<()> {
63        files.write_file(META, &serde_json::to_vec_pretty(self)?)
64    }
65
66    /// Updates this listing from `session`, for a program that writes a
67    /// session no rig is running: the main agent's model and message count,
68    /// `updated` set to now, and each listed value (keys in `listed`, plus
69    /// those already listed), taken from the session's own values, else from
70    /// the first agent by [`main_order`] that has one. A value that is gone
71    /// is unlisted. The title and labels stay.
72    pub fn relist(&mut self, session: &Session, listed: &BTreeSet<String>) {
73        let mut agents: Vec<_> = session.agents.iter().filter(|a| !a.retired).collect();
74        agents.sort_by(|a, b| {
75            main_order(a.parent.is_some(), &a.id).cmp(&main_order(b.parent.is_some(), &b.id))
76        });
77        let main = agents.first();
78        self.messages = main.map_or(0, |main| main.conversation.messages().len());
79        self.model = main.and_then(|main| main.model()).unwrap_or_default();
80        let keys: BTreeSet<String> = listed.iter().chain(self.listed.keys()).cloned().collect();
81        self.listed = keys
82            .into_iter()
83            .filter_map(|key| {
84                let mut holders = agents.iter().map(|agent| &agent.saved);
85                let value = session
86                    .saved
87                    .get(&key)
88                    .or_else(|| holders.find_map(|state| state.get(&key)))?;
89                Some((key, value.clone()))
90            })
91            .collect();
92        self.updated = now_ms();
93    }
94}
95
96/// A title or label of `text`, as a listing shows it: on one line, each
97/// run of whitespace one space, cut to 60 characters (the last an
98/// ellipsis when cut).
99///
100/// ```
101/// use rig_memory::title_of;
102///
103/// assert_eq!(title_of("  fix\n the   parser "), "fix the parser");
104/// assert_eq!(title_of(&"a".repeat(80)).chars().count(), 60);
105/// ```
106pub fn title_of(text: &str) -> String {
107    let line = text.split_whitespace().collect::<Vec<_>>().join(" ");
108    match line.char_indices().nth(TITLE_CHARS) {
109        None => line,
110        Some(_) => {
111            let kept = line.chars().take(TITLE_CHARS - 1);
112            kept.chain(['…']).collect()
113        }
114    }
115}
116
117/// One line of `entry`: who wrote it, then the start of its text, or the
118/// tools it called.
119pub fn excerpt(entry: &EntryRef<'_>) -> String {
120    let (who, text) = match entry.message {
121        Message::System { content } => ("system", content.clone()),
122        Message::User { content } => {
123            let text: Vec<&str> = content
124                .iter()
125                .filter_map(|part| match part {
126                    UserContent::Text(text) => Some(text.text.as_str()),
127                    _ => None,
128                })
129                .collect();
130            match text.is_empty() {
131                true => ("tool results", String::new()),
132                false => ("user", text.join(" ")),
133            }
134        }
135        Message::Assistant(turn) => {
136            let text = turn.text();
137            let calls: Vec<&str> = turn
138                .content
139                .iter()
140                .filter_map(|part| match part {
141                    AssistantContent::ToolCall(call) => Some(call.function.name.as_str()),
142                    _ => None,
143                })
144                .collect();
145            match (text.trim().is_empty(), calls.is_empty()) {
146                (true, false) => ("calls", calls.join(", ")),
147                _ => ("assistant", text),
148            }
149        }
150    };
151    format!("{who}: {}", title_of(&text)).trim_end().to_owned()
152}
153
154#[cfg(test)]
155mod tests;