Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,4 @@ html2text = "0.14"

[dev-dependencies]
tempfile = "3"
filetime = "0.2"
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,9 @@ allowed_jids = ["admin@localhost"]
[memory]
backend = "markdown"
path = "./data/memory"

[session]
idle_timeout_mins = 240 # Auto-archive after 4 hours of inactivity (0 = disabled)
```

Memory is stored as human-readable markdown files, workspace files for global agent configuration and per-JID directories for isolated user data. This makes agent memory inspectable, editable, and git-friendly. Admins can customize agent behavior by creating `instructions.md`, `identity.md`, and `personality.md` in the memory root directory.
Expand All @@ -313,6 +316,7 @@ Each user has a current conversation session (`history.jsonl`) and optionally ar
- **`/new`** archives the current session to `sessions/{YYYYMMDD-HHMMSS}.jsonl` and clears the LLM context.
- **`/forget`** erases the current history, user profile (`user.md`), and memory (`memory.md`) but preserves archived sessions.
- **`/status`** shows the number of messages in the current session and how many sessions have been archived.
- **Session timeout** — idle sessions are automatically archived when the next message arrives after a configurable inactivity period. This is lazy (no background timer) and works per-user and per-room.

Memory layout:

Expand Down
15 changes: 14 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,23 @@ Implemented: the agent supports discrete sessions per user.
- **Memory layout** — `{jid}/history.jsonl` (current session, JSONL format), `{jid}/sessions/*.jsonl` (archived), `{jid}/user.md` (user profile), `{jid}/memory.md` (long-term notes).
- **`/status`** — Reports message count in current session and number of archived sessions.

#### Session timeout ✓

Implemented: idle sessions are automatically archived when the next message arrives.

- **Lazy evaluation** — No background timer. When a message arrives, the runtime checks the session file's modification time. If idle longer than `idle_timeout_mins`, the session is archived (same as `/new`) before processing the new message.
- **Configurable** — `[session]` TOML section with `idle_timeout_mins` (default: 0 = disabled). Per-user and per-room (MUC sessions have their own timeout check).
- **All handlers** — Freshness check runs in `handle_message`, `handle_muc_message`, `handle_reaction`, and `handle_message_with_attachments`.
- **`/status`** — Shows session timeout configuration.

```toml
[session]
idle_timeout_mins = 240 # Archive after 4 hours of inactivity (0 = disabled)
```

#### Future enhancements (not yet implemented)

- **XMPP thread ID mapping** — When the XMPP client sends a `<thread>` element (XEP-0201), the agent maps it to a session. Different thread IDs = different sessions. Messages without a thread ID use the "default" session.
- **Session timeout** — If no message is received for a configurable duration (e.g. 4 hours), the next message implicitly starts a new session. The timeout is per-user.
- **Session context carry-over** — When a new session starts, the agent can optionally summarize the previous session into `context.md`, giving continuity without sending the full old history to the LLM.

### Presence subscription for allowed JIDs ✓
Expand Down
10 changes: 7 additions & 3 deletions docs/XEPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@ Fluux Agent implements the following XMPP standards:

Core XMPP protocol — XML streams, stanza routing, error handling.

**Implementation:** Stream establishment, stanza parsing (quick-xml event-based `StanzaParser`), error condition handling (§4.9.3).
**Implementation:** Stream establishment, stanza parsing (quick-xml event-based `StanzaParser`), error condition handling (§4.9.3), whitespace keepalive (§4.6.1).

**Whitespace keepalive (§4.6.1):** The agent sends periodic whitespace pings (single space byte) to detect dead TCP connections — e.g., after machine sleep/wake. A configurable read timeout triggers a connection probe; if the write fails, the agent declares the connection lost and the reconnection loop takes over. This is non-destructive: a timeout alone does not disconnect — only a failed write does.

**References:**
- `src/xmpp/stanzas.rs:724` — stream error conditions (25 RFC 6120 conditions)
- `src/xmpp/stanzas.rs:446` — `StanzaParser` (event-based XML stream parser)
- `src/xmpp/component.rs` — stream management
- `src/xmpp/component.rs` — stream management, keepalive ping handling, read timeout
- `src/agent/runtime.rs:70` — ping interval timer and read timeout probe logic
- `src/config.rs` — `KeepaliveConfig` (enabled, ping_interval_secs, read_timeout_secs)

---

Expand Down Expand Up @@ -215,7 +219,7 @@ See `docs/DEVELOPING.md` for rationale.
## Version History

- **v0.1** — XEP-0114 (component mode), XEP-0085 (chat states), XEP-0045 (MUC), XEP-0066 (OOB file attachments)
- **v0.2** — XEP-0444 inbound reactions, message ID embedding, C2S client mode (RFC 4616 PLAIN, RFC 5802 SCRAM-SHA-1, STARTTLS), JSONL session format
- **v0.2** — XEP-0444 inbound reactions, message ID embedding, C2S client mode (RFC 4616 PLAIN, RFC 5802 SCRAM-SHA-1, STARTTLS), JSONL session format, RFC 6120 §4.6.1 whitespace keepalive

---

Expand Down
177 changes: 177 additions & 0 deletions src/agent/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,44 @@ impl Memory {
))
}

/// Checks whether the current session is still fresh, based on the file's
/// modification time and the configured idle timeout.
///
/// If the session has been idle for longer than `idle_timeout_mins`, it is
/// automatically archived (same as `/new`) and `Ok(true)` is returned to
/// indicate that a stale session was rotated.
///
/// Returns `Ok(false)` if the session is still fresh or if there is no
/// active session. A timeout of 0 disables the check entirely.
pub fn check_session_freshness(&self, jid: &str, idle_timeout_mins: u64) -> Result<bool> {
if idle_timeout_mins == 0 {
return Ok(false);
}

let user_dir = self.base_path.join(jid);
let history_path = user_dir.join("history.jsonl");

if !history_path.exists() {
return Ok(false);
}

let metadata = fs::metadata(&history_path)?;
let modified = metadata.modified()?;
let elapsed = modified.elapsed().unwrap_or_default();
let timeout = std::time::Duration::from_secs(idle_timeout_mins * 60);

if elapsed > timeout {
info!(
"Session for {jid} idle for {}m (timeout: {idle_timeout_mins}m) — auto-archiving",
elapsed.as_secs() / 60
);
self.new_session(jid)?;
Ok(true)
} else {
Ok(false)
}
}

/// Erases all active memory for a user (history + user profile + memory).
/// Archived sessions are preserved.
pub fn forget(&self, jid: &str) -> Result<String> {
Expand Down Expand Up @@ -2330,4 +2368,143 @@ not valid json
let result = memory.knowledge_search(jid, "anything").unwrap();
assert!(result.contains("No knowledge entries stored yet"));
}

// ── Session freshness tests ─────────────────────────────

#[test]
fn test_check_freshness_disabled_when_zero() {
let dir = tempfile::tempdir().unwrap();
let memory = Memory::open(dir.path()).unwrap();

memory.store_message("user@test", "user", "Hello").unwrap();

// Timeout of 0 means disabled — never archives
let rotated = memory.check_session_freshness("user@test", 0).unwrap();
assert!(!rotated);

// Session still intact
assert_eq!(memory.message_count("user@test").unwrap(), 1);
}

#[test]
fn test_check_freshness_no_session() {
let dir = tempfile::tempdir().unwrap();
let memory = Memory::open(dir.path()).unwrap();

// No history file at all — should return false (no rotation)
let rotated = memory.check_session_freshness("user@test", 60).unwrap();
assert!(!rotated);
}

#[test]
fn test_check_freshness_fresh_session() {
let dir = tempfile::tempdir().unwrap();
let memory = Memory::open(dir.path()).unwrap();

memory.store_message("user@test", "user", "Hello").unwrap();

// Just created — should be fresh with a 60-minute timeout
let rotated = memory.check_session_freshness("user@test", 60).unwrap();
assert!(!rotated);

// Session still intact
assert_eq!(memory.message_count("user@test").unwrap(), 1);
}

#[test]
fn test_check_freshness_stale_session_archives() {
use filetime::FileTime;

let dir = tempfile::tempdir().unwrap();
let memory = Memory::open(dir.path()).unwrap();

memory.store_message("user@test", "user", "Hello").unwrap();
memory.store_message("user@test", "assistant", "Hi!").unwrap();

// Backdate the file's mtime by 2 hours
let history_path = dir.path().join("user@test/history.jsonl");
let two_hours_ago = FileTime::from_unix_time(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
- 7200,
0,
);
filetime::set_file_mtime(&history_path, two_hours_ago).unwrap();

// With a 60-minute timeout, the session should be stale
let rotated = memory.check_session_freshness("user@test", 60).unwrap();
assert!(rotated);

// History should be empty (archived)
assert_eq!(memory.message_count("user@test").unwrap(), 0);

// Archived session should exist
assert_eq!(memory.session_count("user@test").unwrap(), 1);
}

#[test]
fn test_check_freshness_not_stale_within_timeout() {
use filetime::FileTime;

let dir = tempfile::tempdir().unwrap();
let memory = Memory::open(dir.path()).unwrap();

memory.store_message("user@test", "user", "Hello").unwrap();

// Backdate by 30 minutes
let history_path = dir.path().join("user@test/history.jsonl");
let thirty_mins_ago = FileTime::from_unix_time(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
- 1800,
0,
);
filetime::set_file_mtime(&history_path, thirty_mins_ago).unwrap();

// With a 60-minute timeout, 30 minutes of idle is still fresh
let rotated = memory.check_session_freshness("user@test", 60).unwrap();
assert!(!rotated);

// Session still intact
assert_eq!(memory.message_count("user@test").unwrap(), 1);
}

#[test]
fn test_check_freshness_new_session_works_after_archive() {
use filetime::FileTime;

let dir = tempfile::tempdir().unwrap();
let memory = Memory::open(dir.path()).unwrap();

memory.store_message("user@test", "user", "Old message").unwrap();

// Backdate the file
let history_path = dir.path().join("user@test/history.jsonl");
let two_hours_ago = FileTime::from_unix_time(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64
- 7200,
0,
);
filetime::set_file_mtime(&history_path, two_hours_ago).unwrap();

// Auto-archive
memory.check_session_freshness("user@test", 60).unwrap();

// Now store a new message — should start a fresh session
memory.store_message("user@test", "user", "New message").unwrap();

let history = memory.get_history("user@test", 10).unwrap();
assert_eq!(history.len(), 1);
assert_eq!(text(&history[0].content), "New message");

// One archived session
assert_eq!(memory.session_count("user@test").unwrap(), 1);
}
}
Loading