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
//! Conversation-keyed turn store for the portfolio chat loop (WI-4, #2581).
//!
//! Why: DOC-36 §3.2 keys `POST /manager/chat` on a caller-supplied
//! `conversation_key`, "the same shape as SessionProxy's focus-map keying"
//! (`client/proxy.rs`, a `Mutex<HashMap<String, _>>` keyed by conversation key).
//! The chat loop needs recent turns as context so a multi-turn conversation is
//! coherent, but must stay bounded so a long-lived key cannot grow without limit.
//! This is the in-memory half of that state; the durable half is the portfolio
//! palace dual-write (WI-5), which degrades silently when unavailable.
//! What: [`ChatStore`] wraps a `Mutex<LruCache<String, Vec<ChatTurn>>>` keyed
//! IDENTICALLY to the proxy focus map (plain `String` conversation key), with a
//! bounded [`ChatStore::history`]/[`ChatStore::record_exchange`] surface. Each
//! [`ChatTurn`] carries a [`TurnRole`] and its text; the per-key window keeps the
//! most recent [`DEFAULT_MAX_TURNS`] messages, and the key SET itself is capped at
//! [`DEFAULT_MAX_KEYS`] distinct conversations, least-recently-used evicted (#2602)
//! — a local/authenticated-only caller couldn't mint enough distinct keys to
//! matter, but phase 4 fronts this endpoint with public channels (Telegram/Slack),
//! where every inbound chat could carry a fresh key and grow the map forever
//! without this cap.
//! Test: `record_and_history_round_trips`, `history_is_bounded`,
//! `distinct_keys_are_isolated`, `key_set_is_bounded_lru_eviction`,
//! `evicted_key_reads_as_empty_and_can_restart` in `chat_store_tests.rs`.
use NonZeroUsize;
use Mutex;
use LruCache;
/// Maximum stored messages (user + assistant) retained per conversation.
///
/// Why: bounds memory for a long-lived key while keeping enough recent context
/// for a coherent multi-turn reply; 20 messages ≈ 10 exchanges, ample for a
/// portfolio Q&A loop.
/// What: the per-key cap enforced by [`ChatStore::record_exchange`].
/// Test: `history_is_bounded`.
pub const DEFAULT_MAX_TURNS: usize = 20;
/// Maximum number of distinct conversation keys retained at once.
///
/// Why: #2602 — the per-key turn cap bounds memory PER conversation, but never
/// bounded the KEY SET itself; any caller minting distinct keys (e.g. one per
/// inbound message from an unauthenticated public channel) could grow daemon
/// memory forever. 512 distinct live conversations is generously above any
/// expected phase-4 concurrent-channel load while keeping worst-case memory
/// small (512 keys × 20 turns × a short message ≈ low single-digit MB).
/// What: the capacity passed to the backing [`LruCache`]; the least-recently-used
/// key is evicted once a `record_exchange` for a NEW key would exceed it.
/// Test: `key_set_is_bounded_lru_eviction`.
pub const DEFAULT_MAX_KEYS: usize = 512;
/// The speaker of a stored conversation turn.
///
/// Why: replaying history as [`trusty_common::inference::ChatMessage`]s needs the
/// role; a typed enum avoids stringly-typed role bugs at the call site.
/// What: the two roles a read-only portfolio chat produces (no tool/system turns
/// are persisted — those are rebuilt fresh from live state each request).
/// Test: `record_and_history_round_trips`.
/// One stored conversation turn.
///
/// Why: the unit of replayed context; keeping role + content together makes
/// [`ChatStore::history`] a direct source for rebuilding the prompt history.
/// What: the [`TurnRole`] and the turn's text.
/// Test: `record_and_history_round_trips`.
/// Bounded, conversation-keyed store of recent portfolio chat turns.
///
/// Why: the shared, daemon-owned context store threaded through
/// [`crate::daemon::manager::ManagerState`]; keying it exactly like the L2 proxy
/// focus map keeps the L3 chat surface channel-agnostic and consistent with the
/// existing conversation-keying convention. Bounding the key set itself (#2602)
/// with an LRU cache — not just the per-key turn window — keeps daemon memory
/// flat under public-channel-scale key churn once phase 4 lands.
/// What: a `Mutex`-guarded [`LruCache`] from conversation key to a capped
/// `Vec<ChatTurn>`. Cheap to read/write (a short vector clone); no async, no I/O.
/// Test: `record_and_history_round_trips`, `history_is_bounded`,
/// `distinct_keys_are_isolated`, `key_set_is_bounded_lru_eviction`,
/// `evicted_key_reads_as_empty_and_can_restart`.