1use crate::context::ContextCapsule;
30
31const ORDERING_MARKERS: &[&str] = &[
38 "before",
39 "after",
40 "first",
41 "last",
42 "latest",
43 "earliest",
44 "earlier",
45 "later",
46 "order",
47 "ordering",
48 "sequence",
49 "chronological",
50 "chronologically",
51 "timeline",
52 "when",
53 "then",
54 "initially",
55 "originally",
56 "eventually",
57 "previously",
58 "subsequently",
59 "recent",
60 "recently",
61 "since",
62 "until",
63 "history",
64];
65
66pub fn is_ordering_query(query: &str) -> bool {
73 let lower = query.to_ascii_lowercase();
74 lower
75 .split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
76 .any(|word| ORDERING_MARKERS.contains(&word))
77}
78
79fn date_of(created_at: &str) -> &str {
84 created_at.split('T').next().unwrap_or(created_at)
85}
86
87fn dated_summary(summary: &str, date: &str) -> String {
97 match summary.split_once(" - ") {
98 Some((prefix, text)) => format!("{prefix} - [{date}] {text}"),
99 None => format!("[{date}] {summary}"),
100 }
101}
102
103pub fn render_chronologically(
113 capsules: Vec<ContextCapsule>,
114 created_at: &std::collections::HashMap<String, String>,
115) -> Vec<ContextCapsule> {
116 let mut dated: Vec<(String, ContextCapsule)> = Vec::new();
117 let mut undated: Vec<ContextCapsule> = Vec::new();
118
119 for capsule in capsules {
120 match created_at.get(&capsule.expansion_handle) {
121 Some(ts) => dated.push((ts.clone(), capsule)),
122 None => undated.push(capsule),
123 }
124 }
125
126 dated.sort_by(|a, b| a.0.cmp(&b.0));
129
130 let mut out: Vec<ContextCapsule> = dated
131 .into_iter()
132 .map(|(ts, mut capsule)| {
133 capsule.summary = dated_summary(&capsule.summary, date_of(&ts));
134 capsule.token_estimate = capsule.token_estimate.saturating_add(4);
136 capsule
137 })
138 .collect();
139 out.append(&mut undated);
140 out
141}
142
143pub const CHRONOLOGICAL_NOTE: &str =
148 "These memories are in chronological order, oldest first, with the date each was recorded.";
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use std::collections::HashMap;
154
155 fn capsule(handle: &str, summary: &str) -> ContextCapsule {
156 ContextCapsule {
157 id: String::new(),
158 kind: "memory".to_string(),
159 summary: summary.to_string(),
160 token_estimate: 10,
161 expansion_handle: handle.to_string(),
162 provenance: Vec::new(),
163 confidence: 0.9,
164 freshness: 0.5,
165 relevance: 0.0,
166 scope_weight: 0.9,
167 score: 0.5,
168 superseded_hint: false,
169 rerank_policy_tier: 0,
170 claim_revision: None,
171 facts: vec![],
172 rerank_usefulness: None,
173 rerank_trust: None,
174 }
175 }
176
177 fn handles(capsules: &[ContextCapsule]) -> Vec<&str> {
178 capsules
179 .iter()
180 .map(|c| c.expansion_handle.as_str())
181 .collect()
182 }
183
184 #[test]
185 fn ordering_questions_are_recognised() {
186 for query in [
187 "did we switch to thiserror before or after the migration",
188 "what came first, the parser or the lexer",
189 "when did we adopt edition 2024",
190 "show me the timeline of schema changes",
191 "what did we do most recently",
192 ] {
193 assert!(is_ordering_query(query), "should be ordering: {query:?}");
194 }
195 }
196
197 #[test]
200 fn ordinary_questions_are_not_ordering_questions() {
201 for query in [
202 "how do I checkpoint the wal",
203 "add error handling to the parser",
204 "why does the build fail",
205 "what is the schema version",
206 ] {
207 assert!(
208 !is_ordering_query(query),
209 "should not be ordering: {query:?}"
210 );
211 }
212 }
213
214 #[test]
216 fn markers_match_whole_words_only() {
217 assert!(!is_ordering_query("this was an afterthought"));
218 assert!(!is_ordering_query("refactor the ordering_service module"));
219 assert!(is_ordering_query("refactor the ordering service"));
220 }
221
222 #[test]
225 fn capsules_are_reordered_by_time_and_dated() {
226 let mut created = HashMap::new();
227 created.insert("memory:b".to_string(), "2026-01-15T10:00:00Z".to_string());
228 created.insert("memory:a".to_string(), "2026-06-01T09:00:00Z".to_string());
229
230 let ordered = render_chronologically(
232 vec![
233 capsule("memory:a", "project:fact - switched to thiserror"),
234 capsule("memory:b", "project:fact - migrated the schema"),
235 ],
236 &created,
237 );
238
239 assert_eq!(
240 handles(&ordered),
241 vec!["memory:b", "memory:a"],
242 "oldest first"
243 );
244 assert_eq!(
245 ordered[0].summary,
246 "project:fact - [2026-01-15] migrated the schema"
247 );
248 assert_eq!(
249 ordered[1].summary,
250 "project:fact - [2026-06-01] switched to thiserror"
251 );
252 }
253
254 #[test]
259 fn the_date_survives_the_hooks_summary_stripping() {
260 let mut created = HashMap::new();
261 created.insert("memory:a".to_string(), "2026-01-15T10:00:00Z".to_string());
262 let ordered = render_chronologically(
263 vec![capsule("memory:a", "project:fact - switched to thiserror")],
264 &created,
265 );
266 let shown = ordered[0]
267 .summary
268 .split(" - ")
269 .nth(1)
270 .expect("hook renders the text half");
271 assert!(shown.starts_with("[2026-01-15] "), "got: {shown}");
272 }
273
274 #[test]
277 fn a_prefixless_summary_is_dated_at_the_front() {
278 let mut created = HashMap::new();
279 created.insert("memory:a".to_string(), "2026-01-15T10:00:00Z".to_string());
280 let ordered = render_chronologically(vec![capsule("memory:a", "bare text")], &created);
281 assert_eq!(ordered[0].summary, "[2026-01-15] bare text");
282 }
283
284 #[test]
287 fn undated_capsules_are_kept_after_the_timeline() {
288 let mut created = HashMap::new();
289 created.insert("memory:a".to_string(), "2026-01-01T00:00:00Z".to_string());
290
291 let ordered = render_chronologically(
292 vec![
293 capsule("repo_file:src/main.rs", "repo_file:src/main.rs - fn main"),
294 capsule("memory:a", "project:fact - a thing"),
295 ],
296 &created,
297 );
298 assert_eq!(
299 handles(&ordered),
300 vec!["memory:a", "repo_file:src/main.rs"],
301 "dated first, undated kept: {:?}",
302 handles(&ordered)
303 );
304 assert!(
305 !ordered[1].summary.contains('['),
306 "an undated capsule must not be given a date: {}",
307 ordered[1].summary
308 );
309 }
310
311 #[test]
312 fn equal_timestamps_keep_the_brokers_order() {
313 let mut created = HashMap::new();
314 created.insert("memory:a".to_string(), "2026-01-01T00:00:00Z".to_string());
315 created.insert("memory:b".to_string(), "2026-01-01T00:00:00Z".to_string());
316 let ordered = render_chronologically(
317 vec![
318 capsule("memory:a", "project:fact - best match"),
319 capsule("memory:b", "project:fact - second"),
320 ],
321 &created,
322 );
323 assert_eq!(handles(&ordered), vec!["memory:a", "memory:b"]);
324 }
325
326 #[test]
328 fn reordering_never_adds_or_drops_a_capsule() {
329 let mut created = HashMap::new();
330 created.insert("memory:a".to_string(), "2026-03-01T00:00:00Z".to_string());
331 let input = vec![
332 capsule("memory:a", "a"),
333 capsule("memory:b", "b"),
334 capsule("repo_file:x", "x"),
335 ];
336 let ordered = render_chronologically(input.clone(), &created);
337 assert_eq!(ordered.len(), input.len());
338 let mut got = handles(&ordered);
339 got.sort_unstable();
340 let mut want = handles(&input);
341 want.sort_unstable();
342 assert_eq!(got, want);
343 }
344
345 #[test]
346 fn the_token_estimate_accounts_for_the_date_prefix() {
347 let mut created = HashMap::new();
348 created.insert("memory:a".to_string(), "2026-03-01T00:00:00Z".to_string());
349 let ordered = render_chronologically(vec![capsule("memory:a", "a")], &created);
350 assert!(ordered[0].token_estimate > 10, "the prefix costs tokens");
351 }
352}