zagens_core/compaction.rs
1//! Compaction configuration and artifact types — shared between core and runtime-server.
2
3/// Default model used when no model is specified.
4pub const DEFAULT_COMPACTION_MODEL: &str = "deepseek-chat";
5
6/// Hard floor for automatic compaction in v0.8.11+.
7///
8/// Below this token count, `should_compact` returns `false` regardless of
9/// `enabled` or `token_threshold`. The point of the floor is V4 prefix-cache
10/// economics: compaction rewrites the stable prefix, which destroys the KV
11/// cache.
12///
13/// Manual `/compact` slash command bypasses this floor with explicit user
14/// agency.
15pub const MINIMUM_AUTO_COMPACTION_TOKENS: usize = 500_000;
16
17/// Configuration for conversation compaction behavior.
18#[derive(Debug, Clone, PartialEq)]
19pub struct CompactionConfig {
20 pub enabled: bool,
21 pub token_threshold: usize,
22 pub model: String,
23 pub cache_summary: bool,
24 /// Hard floor — `should_compact` returns `false` when total session
25 /// tokens fall below this number, regardless of `enabled` or
26 /// `token_threshold`.
27 pub auto_floor_tokens: usize,
28}
29
30impl Default for CompactionConfig {
31 fn default() -> Self {
32 Self {
33 enabled: true,
34 token_threshold: 800_000,
35 model: DEFAULT_COMPACTION_MODEL.to_string(),
36 cache_summary: true,
37 auto_floor_tokens: MINIMUM_AUTO_COMPACTION_TOKENS,
38 }
39 }
40}
41
42// ── P2-C: CompactionArtifact ───────────────────────────────────────────────────
43
44/// Opaque artifact identifier (UUID string).
45pub type ArtifactId = String;
46
47/// A compaction artifact — records a single compaction event non-destructively.
48///
49/// **Phase 2-C goal**: compaction no longer destroys message history. Instead,
50/// the replaced messages are stored here. The active rendering path skips
51/// `replaced_range` and injects `summary` via the `"memory.compaction"`
52/// `ContextSource`; with a wider budget the original messages can be replayed
53/// verbatim (reversibility gate).
54///
55/// Stored in the `compaction_artifacts` SQLite table (additive migration —
56/// older binaries that do not know about this table simply ignore it).
57///
58/// # Fields
59///
60/// - `id`: stable UUID string, used as the SQLite primary key.
61/// - `session_id`: foreign key into `sessions`.
62/// - `created_at_ms`: wall-clock milliseconds since UNIX epoch at creation.
63/// - `replaced_range`: half-open `[start, end)` index range into the
64/// original `session.messages` vector. Messages at these indices are
65/// "virtually removed" in the compacted view.
66/// - `replaced_messages_json`: JSON-serialised `Vec<Message>` from
67/// `replaced_range`. Preserves the original content so the session can be
68/// replayed without the artifact (reversibility).
69/// - `summary`: LLM-generated summary text injected by
70/// `"memory.compaction"` source.
71/// - `original_tokens`: raw token estimate for the replaced messages.
72/// - `summary_tokens`: token estimate for `summary`.
73#[derive(Debug, Clone)]
74pub struct CompactionArtifact {
75 pub id: ArtifactId,
76 pub session_id: String,
77 pub created_at_ms: i64,
78 /// `[start, end)` index range in the original `session.messages` vector.
79 pub replaced_start: usize,
80 pub replaced_end: usize,
81 /// Original messages preserved for reversibility. Stored as JSON in SQLite.
82 pub replaced_messages_json: String,
83 pub summary: String,
84 pub original_tokens: u32,
85 pub summary_tokens: u32,
86}
87
88impl CompactionArtifact {
89 /// Number of messages replaced by this artifact.
90 #[must_use]
91 pub fn replaced_count(&self) -> usize {
92 self.replaced_end.saturating_sub(self.replaced_start)
93 }
94
95 /// Whether this artifact covers the given message index.
96 #[must_use]
97 pub fn covers_index(&self, idx: usize) -> bool {
98 idx >= self.replaced_start && idx < self.replaced_end
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn compaction_artifact_covers_index() {
108 let art = CompactionArtifact {
109 id: "test-id".to_string(),
110 session_id: "sess".to_string(),
111 created_at_ms: 0,
112 replaced_start: 2,
113 replaced_end: 5,
114 replaced_messages_json: "[]".to_string(),
115 summary: "summary".to_string(),
116 original_tokens: 100,
117 summary_tokens: 20,
118 };
119 assert!(!art.covers_index(1));
120 assert!(art.covers_index(2));
121 assert!(art.covers_index(4));
122 assert!(!art.covers_index(5));
123 assert_eq!(art.replaced_count(), 3);
124 }
125}