Skip to main content

opendev_memory/
playbook.rs

1//! ACE Playbook: structured context store for strategies and insights.
2//!
3//! Mirrors `opendev/core/context_engineering/memory/playbook.py`.
4
5use chrono::Utc;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9use crate::delta::{DeltaBatch, DeltaOperation, DeltaOperationType};
10
11/// Single playbook entry storing a strategy or insight.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct Bullet {
14    pub id: String,
15    pub section: String,
16    pub content: String,
17    #[serde(default)]
18    pub helpful: i64,
19    #[serde(default)]
20    pub harmful: i64,
21    #[serde(default)]
22    pub neutral: i64,
23    pub created_at: String,
24    pub updated_at: String,
25}
26
27impl Bullet {
28    /// Apply metadata updates to counters.
29    pub fn apply_metadata(&mut self, metadata: &HashMap<String, i64>) {
30        if let Some(&v) = metadata.get("helpful") {
31            self.helpful = v;
32        }
33        if let Some(&v) = metadata.get("harmful") {
34            self.harmful = v;
35        }
36        if let Some(&v) = metadata.get("neutral") {
37            self.neutral = v;
38        }
39    }
40
41    /// Increment a counter (helpful/harmful/neutral).
42    pub fn tag(&mut self, tag: &str, increment: i64) -> Result<(), String> {
43        match tag {
44            "helpful" => self.helpful += increment,
45            "harmful" => self.harmful += increment,
46            "neutral" => self.neutral += increment,
47            _ => return Err(format!("Unsupported tag: {tag}")),
48        }
49        self.updated_at = Utc::now().to_rfc3339();
50        Ok(())
51    }
52}
53
54/// Structured context store for accumulated strategies and insights.
55///
56/// The Playbook replaces traditional message history with a curated collection
57/// of strategy entries (bullets) that evolve based on execution feedback.
58#[derive(Debug, Clone)]
59pub struct Playbook {
60    bullets: HashMap<String, Bullet>,
61    sections: HashMap<String, Vec<String>>,
62    next_id: u64,
63}
64
65impl Playbook {
66    /// Create a new empty playbook.
67    pub fn new() -> Self {
68        Self {
69            bullets: HashMap::new(),
70            sections: HashMap::new(),
71            next_id: 0,
72        }
73    }
74
75    /// Add a new bullet to the playbook.
76    pub fn add_bullet(
77        &mut self,
78        section: &str,
79        content: &str,
80        bullet_id: Option<&str>,
81        metadata: Option<&HashMap<String, i64>>,
82    ) -> &Bullet {
83        let id = bullet_id
84            .map(String::from)
85            .unwrap_or_else(|| self.generate_id(section));
86        let now = Utc::now().to_rfc3339();
87        let mut bullet = Bullet {
88            id: id.clone(),
89            section: section.to_string(),
90            content: content.to_string(),
91            helpful: 0,
92            harmful: 0,
93            neutral: 0,
94            created_at: now.clone(),
95            updated_at: now,
96        };
97        if let Some(meta) = metadata {
98            bullet.apply_metadata(meta);
99        }
100        self.bullets.insert(id.clone(), bullet);
101        self.sections
102            .entry(section.to_string())
103            .or_default()
104            .push(id.clone());
105        self.bullets.get(&id).unwrap()
106    }
107
108    /// Update an existing bullet.
109    pub fn update_bullet(
110        &mut self,
111        bullet_id: &str,
112        content: Option<&str>,
113        metadata: Option<&HashMap<String, i64>>,
114    ) -> Option<&Bullet> {
115        let bullet = self.bullets.get_mut(bullet_id)?;
116        if let Some(c) = content {
117            bullet.content = c.to_string();
118        }
119        if let Some(meta) = metadata {
120            bullet.apply_metadata(meta);
121        }
122        bullet.updated_at = Utc::now().to_rfc3339();
123        self.bullets.get(bullet_id)
124    }
125
126    /// Tag a bullet to update its counters.
127    pub fn tag_bullet(&mut self, bullet_id: &str, tag: &str, increment: i64) -> Option<&Bullet> {
128        let bullet = self.bullets.get_mut(bullet_id)?;
129        let _ = bullet.tag(tag, increment);
130        self.bullets.get(bullet_id)
131    }
132
133    /// Remove a bullet from the playbook.
134    pub fn remove_bullet(&mut self, bullet_id: &str) {
135        if let Some(bullet) = self.bullets.remove(bullet_id)
136            && let Some(section_ids) = self.sections.get_mut(&bullet.section)
137        {
138            section_ids.retain(|id| id != bullet_id);
139            if section_ids.is_empty() {
140                self.sections.remove(&bullet.section);
141            }
142        }
143    }
144
145    /// Get a bullet by ID.
146    pub fn get_bullet(&self, bullet_id: &str) -> Option<&Bullet> {
147        self.bullets.get(bullet_id)
148    }
149
150    /// Get all bullets.
151    pub fn bullets(&self) -> Vec<&Bullet> {
152        self.bullets.values().collect()
153    }
154
155    /// Get number of bullets.
156    pub fn bullet_count(&self) -> usize {
157        self.bullets.len()
158    }
159
160    /// Get section names.
161    pub fn section_names(&self) -> Vec<&str> {
162        self.sections.keys().map(String::as_str).collect()
163    }
164
165    // ------------------------------------------------------------------ //
166    // Serialization
167    // ------------------------------------------------------------------ //
168
169    /// Convert to serializable dictionary.
170    pub fn to_dict(&self) -> serde_json::Value {
171        let bullets_map: serde_json::Map<String, serde_json::Value> = self
172            .bullets
173            .iter()
174            .map(|(id, bullet)| (id.clone(), serde_json::to_value(bullet).unwrap_or_default()))
175            .collect();
176        serde_json::json!({
177            "bullets": bullets_map,
178            "sections": self.sections,
179            "next_id": self.next_id,
180        })
181    }
182
183    /// Load from dictionary.
184    pub fn from_dict(payload: &serde_json::Value) -> Self {
185        let mut instance = Self::new();
186
187        if let Some(bullets_obj) = payload.get("bullets").and_then(|v| v.as_object()) {
188            for (id, val) in bullets_obj {
189                if let Ok(bullet) = serde_json::from_value::<Bullet>(val.clone()) {
190                    instance.bullets.insert(id.clone(), bullet);
191                }
192            }
193        }
194
195        if let Some(sections_obj) = payload.get("sections").and_then(|v| v.as_object()) {
196            for (section, ids_val) in sections_obj {
197                if let Some(ids_arr) = ids_val.as_array() {
198                    let ids: Vec<String> = ids_arr
199                        .iter()
200                        .filter_map(|v| v.as_str().map(String::from))
201                        .collect();
202                    instance.sections.insert(section.clone(), ids);
203                }
204            }
205        }
206
207        instance.next_id = payload.get("next_id").and_then(|v| v.as_u64()).unwrap_or(0);
208
209        instance
210    }
211
212    /// Serialize to JSON string.
213    pub fn dumps(&self) -> String {
214        serde_json::to_string_pretty(&self.to_dict()).unwrap_or_default()
215    }
216
217    /// Deserialize from JSON string.
218    pub fn loads(data: &str) -> Result<Self, serde_json::Error> {
219        let payload: serde_json::Value = serde_json::from_str(data)?;
220        Ok(Self::from_dict(&payload))
221    }
222
223    /// Save playbook to JSON file.
224    pub fn save_to_file(&self, path: &std::path::Path) -> std::io::Result<()> {
225        if let Some(parent) = path.parent() {
226            std::fs::create_dir_all(parent)?;
227        }
228        std::fs::write(path, self.dumps())
229    }
230
231    /// Load playbook from JSON file.
232    pub fn load_from_file(path: &std::path::Path) -> Result<Self, Box<dyn std::error::Error>> {
233        let content = std::fs::read_to_string(path)?;
234        Ok(Self::loads(&content)?)
235    }
236
237    // ------------------------------------------------------------------ //
238    // Delta operations
239    // ------------------------------------------------------------------ //
240
241    /// Apply a batch of delta operations.
242    pub fn apply_delta(&mut self, delta: &DeltaBatch) {
243        for operation in &delta.operations {
244            self.apply_operation(operation);
245        }
246    }
247
248    /// Apply a single delta operation.
249    fn apply_operation(&mut self, operation: &DeltaOperation) {
250        match operation.op_type {
251            DeltaOperationType::Add => {
252                self.add_bullet(
253                    &operation.section,
254                    operation.content.as_deref().unwrap_or(""),
255                    operation.bullet_id.as_deref(),
256                    if operation.metadata.is_empty() {
257                        None
258                    } else {
259                        Some(&operation.metadata)
260                    },
261                );
262            }
263            DeltaOperationType::Update => {
264                if let Some(ref bid) = operation.bullet_id {
265                    self.update_bullet(
266                        bid,
267                        operation.content.as_deref(),
268                        if operation.metadata.is_empty() {
269                            None
270                        } else {
271                            Some(&operation.metadata)
272                        },
273                    );
274                }
275            }
276            DeltaOperationType::Tag => {
277                if let Some(ref bid) = operation.bullet_id {
278                    let valid_tags = ["helpful", "harmful", "neutral"];
279                    for (tag, &increment) in &operation.metadata {
280                        if valid_tags.contains(&tag.as_str()) {
281                            self.tag_bullet(bid, tag, increment);
282                        }
283                    }
284                }
285            }
286            DeltaOperationType::Remove => {
287                if let Some(ref bid) = operation.bullet_id {
288                    self.remove_bullet(bid);
289                }
290            }
291        }
292    }
293
294    // ------------------------------------------------------------------ //
295    // Presentation helpers
296    // ------------------------------------------------------------------ //
297
298    /// Return playbook as formatted string for LLM prompting.
299    pub fn as_prompt(&self) -> String {
300        if self.bullets.is_empty() {
301            return String::new();
302        }
303        let mut parts = Vec::new();
304        let mut sorted_sections: Vec<_> = self.sections.iter().collect();
305        sorted_sections.sort_by_key(|(name, _)| *name);
306
307        for (section, bullet_ids) in sorted_sections {
308            parts.push(format!("## {section}"));
309            for bid in bullet_ids {
310                if let Some(bullet) = self.bullets.get(bid) {
311                    let counters = format!(
312                        "(helpful={}, harmful={}, neutral={})",
313                        bullet.helpful, bullet.harmful, bullet.neutral
314                    );
315                    parts.push(format!("- [{}] {} {}", bullet.id, bullet.content, counters));
316                }
317            }
318        }
319        parts.join("\n")
320    }
321
322    /// Get playbook statistics.
323    pub fn stats(&self) -> PlaybookStats {
324        let mut helpful = 0i64;
325        let mut harmful = 0i64;
326        let mut neutral = 0i64;
327        for bullet in self.bullets.values() {
328            helpful += bullet.helpful;
329            harmful += bullet.harmful;
330            neutral += bullet.neutral;
331        }
332        PlaybookStats {
333            sections: self.sections.len(),
334            bullets: self.bullets.len(),
335            helpful,
336            harmful,
337            neutral,
338        }
339    }
340
341    // ------------------------------------------------------------------ //
342    // Internal helpers
343    // ------------------------------------------------------------------ //
344
345    fn generate_id(&mut self, section: &str) -> String {
346        self.next_id += 1;
347        let prefix = section
348            .split_whitespace()
349            .next()
350            .unwrap_or("bullet")
351            .to_lowercase();
352        format!("{prefix}-{:05}", self.next_id)
353    }
354}
355
356impl Default for Playbook {
357    fn default() -> Self {
358        Self::new()
359    }
360}
361
362/// Playbook statistics.
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct PlaybookStats {
365    pub sections: usize,
366    pub bullets: usize,
367    pub helpful: i64,
368    pub harmful: i64,
369    pub neutral: i64,
370}
371
372#[cfg(test)]
373#[path = "playbook_tests.rs"]
374mod tests;