ai-agent 0.13.4

Idiomatic agent sdk inspired by the claude code source leak
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! Session Memory - automatic conversation summarization
//!
//! Ported from ~/claudecode/openclaudecode/src/services/SessionMemory/sessionMemory.ts
//!
//! Session memory automatically maintains a markdown file with notes about the current conversation.
//! It runs periodically in the background using a forked subagent to extract key information
//! without interrupting the main conversation flow.

use crate::constants::env::system;
use crate::types::*;
use crate::AgentError;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::LazyLock;
use std::sync::Mutex;

/// Default configuration for session memory
pub const DEFAULT_SESSION_MEMORY_CONFIG: SessionMemoryConfig = SessionMemoryConfig {
    minimum_message_tokens_to_init: 10000,
    minimum_tokens_between_update: 5000,
    tool_calls_between_updates: 3,
};

/// Session memory configuration
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SessionMemoryConfig {
    /// Minimum context window tokens before initializing session memory
    pub minimum_message_tokens_to_init: u32,
    /// Minimum context window growth (in tokens) between updates
    pub minimum_tokens_between_update: u32,
    /// Number of tool calls between session memory updates
    pub tool_calls_between_updates: u32,
}

impl Default for SessionMemoryConfig {
    fn default() -> Self {
        DEFAULT_SESSION_MEMORY_CONFIG
    }
}

/// Session memory state
pub struct SessionMemoryState {
    config: Mutex<SessionMemoryConfig>,
    initialized: AtomicBool,
    tokens_at_last_extraction: AtomicU64,
    /// Last summarized message index (not UUID since Message lacks id field)
    last_summarized_index: Mutex<Option<usize>>,
    extraction_in_progress: AtomicBool,
}

impl SessionMemoryState {
    pub fn new() -> Self {
        Self {
            config: Mutex::new(DEFAULT_SESSION_MEMORY_CONFIG),
            initialized: AtomicBool::new(false),
            tokens_at_last_extraction: AtomicU64::new(0),
            last_summarized_index: Mutex::new(None),
            extraction_in_progress: AtomicBool::new(false),
        }
    }

    pub fn is_initialized(&self) -> bool {
        self.initialized.load(Ordering::SeqCst)
    }

    pub fn mark_initialized(&self) {
        self.initialized.store(true, Ordering::SeqCst);
    }

    pub fn get_config(&self) -> SessionMemoryConfig {
        self.config.lock().unwrap().clone()
    }

    pub fn set_config(&self, config: SessionMemoryConfig) {
        *self.config.lock().unwrap() = config;
    }

    pub fn get_tokens_at_last_extraction(&self) -> u64 {
        self.tokens_at_last_extraction.load(Ordering::SeqCst)
    }

    pub fn set_tokens_at_last_extraction(&self, tokens: u64) {
        self.tokens_at_last_extraction
            .store(tokens, Ordering::SeqCst);
    }

    pub fn get_last_summarized_index(&self) -> Option<usize> {
        *self.last_summarized_index.lock().unwrap()
    }

    pub fn set_last_summarized_index(&self, index: Option<usize>) {
        *self.last_summarized_index.lock().unwrap() = index;
    }

    pub fn is_extraction_in_progress(&self) -> bool {
        self.extraction_in_progress.load(Ordering::SeqCst)
    }

    pub fn start_extraction(&self) {
        self.extraction_in_progress.store(true, Ordering::SeqCst);
    }

    pub fn end_extraction(&self) {
        self.extraction_in_progress.store(false, Ordering::SeqCst);
    }
}

impl Default for SessionMemoryState {
    fn default() -> Self {
        Self::new()
    }
}

/// Global session memory state
static SESSION_MEMORY_STATE: LazyLock<SessionMemoryState> = LazyLock::new(SessionMemoryState::new);

/// Get the session memory state
pub fn get_session_memory_state() -> &'static SessionMemoryState {
    &SESSION_MEMORY_STATE
}

/// Get the session memory directory
pub fn get_session_memory_dir() -> PathBuf {
    let home = std::env::var(system::HOME)
        .or_else(|_| std::env::var(system::USERPROFILE))
        .unwrap_or_else(|_| "/tmp".to_string());
    PathBuf::from(home)
        .join(".open-agent-sdk")
        .join("session_memory")
}

/// Get the session memory file path
pub fn get_session_memory_path() -> PathBuf {
    get_session_memory_dir().join("notes.md")
}

/// Check if session memory has been initialized
pub fn is_session_memory_initialized() -> bool {
    SESSION_MEMORY_STATE.is_initialized()
}

/// Mark session memory as initialized
pub fn mark_session_memory_initialized() {
    SESSION_MEMORY_STATE.mark_initialized();
}

/// Get current session memory configuration
pub fn get_session_memory_config() -> SessionMemoryConfig {
    SESSION_MEMORY_STATE.get_config()
}

/// Set session memory configuration
pub fn set_session_memory_config(config: SessionMemoryConfig) {
    SESSION_MEMORY_STATE.set_config(config);
}

/// Get the last summarized message index
pub fn get_last_summarized_message_id() -> Option<usize> {
    SESSION_MEMORY_STATE.get_last_summarized_index()
}

/// Set the last summarized message index
pub fn set_last_summarized_message_id(message_id: Option<usize>) {
    SESSION_MEMORY_STATE.set_last_summarized_index(message_id);
}

/// Check if we've met the initialization threshold
pub fn has_met_initialization_threshold(current_token_count: u64) -> bool {
    let config = get_session_memory_config();
    current_token_count >= config.minimum_message_tokens_to_init as u64
}

/// Check if we've met the update threshold
pub fn has_met_update_threshold(current_token_count: u64) -> bool {
    let config = get_session_memory_config();
    let tokens_at_last = SESSION_MEMORY_STATE.get_tokens_at_last_extraction();
    let tokens_since_last = current_token_count.saturating_sub(tokens_at_last);
    tokens_since_last >= config.minimum_tokens_between_update as u64
}

/// Get tool calls between updates
pub fn get_tool_calls_between_updates() -> u32 {
    get_session_memory_config().tool_calls_between_updates
}

/// Record token count at extraction time
pub fn record_extraction_token_count(token_count: u64) {
    SESSION_MEMORY_STATE.set_tokens_at_last_extraction(token_count);
}

/// Count tool calls since a given message index
pub fn count_tool_calls_since(messages: &[Message], since_index: Option<usize>) -> usize {
    let mut tool_call_count = 0;
    let start_idx = since_index.unwrap_or(0);

    for (i, message) in messages.iter().enumerate() {
        if i < start_idx {
            continue;
        }

        if message.role == MessageRole::Assistant {
            // Count tool calls in this message
            // In Rust we store content as string, so we approximate
            if message.content.contains("tool_use") || message.tool_calls.is_some() {
                tool_call_count += 1;
            }
        }
    }

    tool_call_count
}

/// Check if we should extract memory based on thresholds
pub fn should_extract_memory(messages: &[Message]) -> bool {
    // Estimate token count
    let current_token_count = estimate_message_tokens(messages);

    // Check initialization threshold
    if !is_session_memory_initialized() {
        if !has_met_initialization_threshold(current_token_count) {
            return false;
        }
        mark_session_memory_initialized();
    }

    // Check token threshold
    let has_met_token_threshold = has_met_update_threshold(current_token_count);

    // Check tool call threshold
    let last_index = get_last_summarized_message_id();
    let tool_calls_since_last = count_tool_calls_since(messages, last_index);
    let has_met_tool_call_threshold =
        tool_calls_since_last >= get_tool_calls_between_updates() as usize;

    // Check if last assistant turn has tool calls (unsafe to extract)
    let has_tool_calls_in_last_turn = has_tool_calls_in_last_assistant_turn(messages);

    // Trigger extraction when:
    // 1. Both thresholds are met (tokens AND tool calls), OR
    // 2. No tool calls in last turn AND token threshold is met
    let should_extract = (has_met_token_threshold && has_met_tool_call_threshold)
        || (has_met_token_threshold && !has_tool_calls_in_last_turn);

    if should_extract {
        // Store the last message index
        if !messages.is_empty() {
            set_last_summarized_message_id(Some(messages.len() - 1));
        }
    }

    should_extract
}

/// Check if last assistant turn has tool calls
fn has_tool_calls_in_last_assistant_turn(messages: &[Message]) -> bool {
    // Find last assistant message and check for tool calls
    for message in messages.iter().rev() {
        if message.role == MessageRole::Assistant {
            // Check for tool calls
            if message.tool_calls.is_some() {
                return true;
            }
            // Also check content for tool_use blocks
            if message.content.contains("tool_use") {
                return true;
            }
            // Found last assistant message without tool calls
            return false;
        }
    }
    false
}

/// Estimate token count for messages
fn estimate_message_tokens(messages: &[Message]) -> u64 {
    // Simple estimation: ~4 characters per token
    let total_chars: usize = messages.iter().map(|m| m.content.len()).sum();
    (total_chars / 4) as u64
}

/// Get session memory content from file
pub async fn get_session_memory_content() -> Result<Option<String>, AgentError> {
    let path = get_session_memory_path();

    if !path.exists() {
        return Ok(None);
    }

    match tokio::fs::read_to_string(&path).await {
        Ok(content) => Ok(Some(content)),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
        Err(e) => Err(AgentError::Io(e)),
    }
}

/// Initialize session memory file with template
pub async fn init_session_memory_file() -> Result<String, AgentError> {
    let dir = get_session_memory_dir();
    let path = get_session_memory_path();

    // Create directory
    tokio::fs::create_dir_all(&dir)
        .await
        .map_err(AgentError::Io)?;

    // Check if file already exists
    if !path.exists() {
        // Create with template
        let template = get_session_memory_template();
        tokio::fs::write(&path, template)
            .await
            .map_err(AgentError::Io)?;
    }

    // Return current content
    match tokio::fs::read_to_string(&path).await {
        Ok(content) => Ok(content),
        Err(e) => Err(AgentError::Io(e)),
    }
}

/// Get session memory template
fn get_session_memory_template() -> String {
    r#"# Session Notes

This file contains automatically extracted notes about the current conversation.

## Key Points

-

## Decisions Made

-

## Open Items

-

## Context

"#
    .to_string()
}

/// Manual extraction result
#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct ManualExtractionResult {
    pub success: bool,
    pub memory_path: Option<String>,
    pub error: Option<String>,
}

/// Wait for any in-progress extraction to complete
pub async fn wait_for_session_memory_extraction() {
    // In Rust, this would need async coordination
    // For now, simplified implementation
    while SESSION_MEMORY_STATE.is_extraction_in_progress() {
        tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
    }
}

/// Reset session memory state (for testing)
pub fn reset_session_memory_state() {
    SESSION_MEMORY_STATE.set_config(DEFAULT_SESSION_MEMORY_CONFIG);
    SESSION_MEMORY_STATE.set_tokens_at_last_extraction(0);
    SESSION_MEMORY_STATE.set_last_summarized_index(None);
    // Note: can't reset atomic bool without interior mutability
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_default_config() {
        let config = DEFAULT_SESSION_MEMORY_CONFIG;
        assert_eq!(config.minimum_message_tokens_to_init, 10000);
        assert_eq!(config.minimum_tokens_between_update, 5000);
        assert_eq!(config.tool_calls_between_updates, 3);
    }

    #[test]
    fn test_session_memory_state() {
        let state = SessionMemoryState::new();
        assert!(!state.is_initialized());

        state.mark_initialized();
        assert!(state.is_initialized());
    }

    #[test]
    fn test_has_met_initialization_threshold() {
        reset_session_memory_state();
        assert!(has_met_initialization_threshold(10000));
        assert!(!has_met_initialization_threshold(9999));
    }

    #[test]
    fn test_has_met_update_threshold() {
        reset_session_memory_state();
        record_extraction_token_count(5000);
        assert!(has_met_update_threshold(10000));
        assert!(!has_met_update_threshold(7499));
    }
}