1use std::collections::HashMap;
13use std::sync::Arc;
14
15use async_trait::async_trait;
16
17use lash_core::PreparedContext;
18use lash_core::plugin::{
19 HistoryError, HistoryRewriter, HistoryState, PluginError, PluginFactory, PluginOptions,
20 PluginRegistrar, PluginSessionContext, RewriteContext, RewriteTrigger, SessionContextSurface,
21 SessionCreateRequest, SessionPlugin, SessionStartPoint, TurnContextTransform,
22 TurnTransformContext,
23};
24use lash_core::{
25 InputItem, Message, MessageOrigin, MessageRole, Part, PartKind, PromptUsage, SessionSnapshot,
26 TurnInput,
27};
28
29const PRUNE_RECENT_USER_TURNS: usize = 2;
30const COMPACTION_BUFFER_TOKENS: usize = 20_000;
31const COMPACTION_KEEP_RECENT_TOKENS: usize = 20_000;
32const PRUNE_CONTEXT_THRESHOLD: f64 = 0.6;
33pub(crate) const ROLLING_HISTORY_PLUGIN_ID: &str = "rolling_history";
36const COMPACTION_SUMMARY_TITLE: &str = "Compaction summary:";
37const COMPACTION_PROMPT: &str = "Provide a detailed summary of the conversation above so a later session can continue the work without the full history.\n\nUse this template:\n---\n## Goal\n[What is the user trying to accomplish?]\n\n## Instructions\n- [Relevant instructions or constraints]\n\n## Discoveries\n[Important findings, failures, or decisions]\n\n## Accomplished\n[What is done, what is in progress, what remains]\n\n## Relevant files / directories\n[List important files or directories]\n---";
38const PRUNED_IMAGE_PLACEHOLDER: &str = "[Image omitted from older context]";
39const COMPACTED_IMAGE_PLACEHOLDER: &str = "[Image omitted during compaction]";
40
41#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
42pub struct RollingHistoryConfig;
43
44fn compaction_update_prompt(previous_summary: &str) -> String {
45 format!(
46 "A previous compaction summary exists (shown below). Update it with information from the conversation above.\n\n\
47 Rules:\n\
48 - PRESERVE all existing information from the previous summary\n\
49 - ADD new progress, decisions, and context from the new messages\n\
50 - Move items from in-progress to done where applicable\n\
51 - PRESERVE exact file paths, function names, and error messages\n\n\
52 Previous summary:\n{previous_summary}\n\n\
53 Use this template:\n---\n\
54 ## Goal\n[What is the user trying to accomplish?]\n\n\
55 ## Instructions\n- [Relevant instructions or constraints]\n\n\
56 ## Discoveries\n[Important findings, failures, or decisions]\n\n\
57 ## Accomplished\n[What is done, what is in progress, what remains]\n\n\
58 ## Relevant files / directories\n[List important files or directories]\n---"
59 )
60}
61
62fn with_instructions(base: &str, instructions: Option<&str>) -> String {
63 match instructions {
64 Some(text) if !text.trim().is_empty() => {
65 format!("{base}\n\nAdditional focus:\n{}\n", text.trim())
66 }
67 _ => base.to_string(),
68 }
69}
70
71fn leading_system_prefix_len(msgs: &[Message]) -> usize {
72 msgs.iter()
73 .take_while(|msg| msg.role == MessageRole::System)
74 .count()
75}
76
77fn approx_token_count(text: &str) -> usize {
78 text.len().div_ceil(4)
79}
80
81fn strip_image_attachment(part: &mut Part, placeholder: &str) -> bool {
82 if !matches!(part.kind, PartKind::Image) || part.attachment.is_none() {
83 return false;
84 }
85 part.attachment = None;
86 part.content = placeholder.to_string();
87 true
88}
89
90fn prune_old_images(messages: &mut [Message]) -> bool {
91 let mut changed = false;
92 let mut recent_user_turns = 0usize;
93
94 'scan: for msg_idx in (0..messages.len()).rev() {
95 if is_compaction_summary_message(&messages[msg_idx]) {
96 break 'scan;
97 }
98 if messages[msg_idx].role == MessageRole::User {
99 recent_user_turns += 1;
100 }
101 if recent_user_turns < PRUNE_RECENT_USER_TURNS {
102 continue;
103 }
104 for part in std::sync::Arc::make_mut(&mut messages[msg_idx].parts).iter_mut() {
105 changed |= strip_image_attachment(part, PRUNED_IMAGE_PLACEHOLDER);
106 }
107 }
108
109 changed
110}
111
112fn strip_all_image_attachments(messages: &mut [Message], placeholder: &str) -> bool {
113 let mut changed = false;
114 for message in messages {
115 for part in std::sync::Arc::make_mut(&mut message.parts).iter_mut() {
116 changed |= strip_image_attachment(part, placeholder);
117 }
118 }
119 changed
120}
121
122fn is_compaction_summary_message(message: &Message) -> bool {
123 matches!(
124 message.origin,
125 Some(MessageOrigin::Plugin { ref plugin_id, .. }) if plugin_id == ROLLING_HISTORY_PLUGIN_ID
126 )
127}
128
129fn latest_user_index(messages: &[Message]) -> Option<usize> {
130 messages
131 .iter()
132 .rposition(|message| matches!(message.role, MessageRole::User))
133}
134
135fn find_compaction_cut_point(messages: &[Message], prefix_len: usize) -> usize {
139 let start = messages[prefix_len..]
140 .iter()
141 .rposition(is_compaction_summary_message)
142 .map(|i| prefix_len + i + 1)
143 .unwrap_or(prefix_len);
144
145 let mut accumulated = 0usize;
146 for idx in (start..messages.len()).rev() {
147 for part in messages[idx].parts.iter() {
148 accumulated += approx_token_count(&part.content);
149 if part.attachment.is_some() {
150 accumulated += 1200; }
152 }
153 if accumulated >= COMPACTION_KEEP_RECENT_TOKENS && messages[idx].role == MessageRole::User {
154 return idx;
155 }
156 }
157 latest_user_index(messages).unwrap_or(messages.len())
158}
159
160fn pruning_needed(prompt_usage: Option<&PromptUsage>, max_context_tokens: Option<usize>) -> bool {
161 let Some(usage) = prompt_usage else {
162 return false;
163 };
164 let Some(max_context) = max_context_tokens else {
165 return false;
166 };
167 if max_context == 0 {
168 return false;
169 }
170 (usage.context_budget_tokens as f64 / max_context as f64) >= PRUNE_CONTEXT_THRESHOLD
171}
172
173fn extract_previous_summary(messages: &[Message]) -> Option<String> {
174 messages.iter().rev().find_map(|m| {
175 if !is_compaction_summary_message(m) {
176 return None;
177 }
178 m.parts.first().map(|p| {
179 p.content
180 .strip_prefix(COMPACTION_SUMMARY_TITLE)
181 .unwrap_or(&p.content)
182 .trim()
183 .to_string()
184 })
185 })
186}
187
188fn compaction_needed(
189 prompt_usage: Option<&PromptUsage>,
190 max_context_tokens: Option<usize>,
191) -> bool {
192 let Some(usage) = prompt_usage else {
193 return false;
194 };
195 let Some(max_context) = max_context_tokens else {
196 return false;
197 };
198 let usable = max_context.saturating_sub(COMPACTION_BUFFER_TOKENS.min(max_context));
199 usage.context_budget_tokens >= usable
200}
201
202fn compaction_turn_id(parent_turn_id: &str) -> String {
203 format!("{parent_turn_id}:rolling-history-compaction")
204}
205
206async fn summarize_compaction_prefix(
207 session_id: &str,
208 state: &SessionSnapshot,
209 prefix_messages: Vec<Message>,
210 instructions: Option<&str>,
211 session_lifecycle: Arc<dyn lash_core::plugin::runtime_host::SessionLifecycleService>,
212 scoped_effect_controller: lash_core::ScopedEffectController<'_>,
213) -> Result<Option<String>, HistoryError> {
214 if prefix_messages.is_empty() {
215 return Ok(None);
216 }
217
218 let mut snapshot = lash_core::runtime::RuntimeSessionState::from_snapshot(state.clone());
219 snapshot.policy.max_turns = Some(1);
220 let mut messages = prefix_messages;
221 strip_all_image_attachments(&mut messages, COMPACTED_IMAGE_PLACEHOLDER);
222 snapshot.execution_state_snapshot = None;
223 snapshot.last_prompt_usage = None;
224 let previous_summary = extract_previous_summary(&messages);
225 snapshot.replace_active_read_state(&messages);
226
227 let compaction_session_id = format!("{session_id}-compaction");
228 let mut policy = snapshot.policy.clone();
229 policy.max_turns = Some(1);
230 let request = SessionCreateRequest::child(
231 session_id,
232 SessionStartPoint::Snapshot {
233 snapshot: Box::new(snapshot.to_snapshot()),
234 },
235 policy,
236 PluginOptions::default(),
237 "compaction",
238 )
239 .with_context_surface(SessionContextSurface {
240 include_base_tools: false,
241 tool_providers: Vec::new(),
242 prompt_contributions: Vec::new(),
243 })
244 .with_session_id(compaction_session_id);
245 let handle = session_lifecycle
246 .create_session(request)
247 .await
248 .map_err(HistoryError::from)?;
249
250 let base_prompt = match previous_summary {
251 Some(prev) => compaction_update_prompt(&prev),
252 None => COMPACTION_PROMPT.to_string(),
253 };
254 let prompt_text = with_instructions(&base_prompt, instructions);
255
256 let turn_id = compaction_turn_id(scoped_effect_controller.scope_id());
257 let compaction_effect_controller = lash_core::ScopedEffectController::borrowed(
258 scoped_effect_controller.controller(),
259 lash_core::EffectScope::turn(&handle.session_id, &turn_id),
260 )
261 .map_err(|err| HistoryError::Session(err.to_string()))?;
262 let request = lash_core::SessionTurnRequest::new(
263 &handle.session_id,
264 &turn_id,
265 TurnInput {
266 items: vec![InputItem::Text { text: prompt_text }],
267 image_blobs: HashMap::new(),
268 protocol_turn_options: None,
269 trace_turn_id: None,
270 protocol_extension: None,
271 turn_context: lash_core::TurnContext::default(),
272 },
273 compaction_effect_controller,
274 )
275 .map_err(|err| HistoryError::Session(err.to_string()))?;
276 let turn = session_lifecycle.start_turn(request).await;
277 let _ = session_lifecycle.close_session(&handle.session_id).await;
278 let turn = turn.map_err(HistoryError::from)?;
279 let summary = turn.assistant_output.safe_text.trim().to_string();
280 if summary.is_empty() {
281 return Ok(None);
282 }
283 Ok(Some(summary))
284}
285
286fn apply_compaction_summary(messages: &[Message], summary: &str, cut_point: usize) -> Vec<Message> {
287 let prefix_len = leading_system_prefix_len(messages);
288 if cut_point <= prefix_len || cut_point > messages.len() {
289 return messages.to_vec();
290 }
291
292 let mut out = Vec::new();
293 out.extend_from_slice(&messages[..prefix_len]);
294 out.push(Message {
295 id: "compaction-summary".to_string(),
296 role: MessageRole::Assistant,
297 parts: lash_core::shared_parts(vec![Part {
298 id: "compaction-summary.p0".to_string(),
299 kind: PartKind::Prose,
300 content: format!("{COMPACTION_SUMMARY_TITLE}\n{summary}"),
301 attachment: None,
302 tool_call_id: None,
303 tool_name: None,
304 tool_replay: None,
305 prune_state: lash_core::PruneState::Intact,
306 reasoning_meta: None,
307 response_meta: None,
308 }]),
309 origin: Some(MessageOrigin::Plugin {
310 plugin_id: ROLLING_HISTORY_PLUGIN_ID.to_string(),
311 transient: false,
312 }),
313 });
314 out.extend_from_slice(&messages[cut_point..]);
315 out
316}
317
318async fn compact_messages_core(
319 session_id: &str,
320 state: &SessionSnapshot,
321 messages: &[Message],
322 instructions: Option<&str>,
323 session_lifecycle: Arc<dyn lash_core::plugin::runtime_host::SessionLifecycleService>,
324 scoped_effect_controller: lash_core::ScopedEffectController<'_>,
325) -> Result<Option<Vec<Message>>, HistoryError> {
326 let prefix_len = leading_system_prefix_len(messages);
327 let cut_point = find_compaction_cut_point(messages, prefix_len);
328 if cut_point <= prefix_len {
329 return Ok(None);
330 }
331 let prefix_messages = messages[prefix_len..cut_point].to_vec();
332 let Some(summary) = summarize_compaction_prefix(
333 session_id,
334 state,
335 prefix_messages,
336 instructions,
337 session_lifecycle,
338 scoped_effect_controller,
339 )
340 .await?
341 else {
342 return Ok(None);
343 };
344 Ok(Some(apply_compaction_summary(
345 messages, &summary, cut_point,
346 )))
347}
348
349pub struct RollingHistoryPluginFactory {
350 config: RollingHistoryConfig,
351}
352
353impl RollingHistoryPluginFactory {
354 pub fn new(config: RollingHistoryConfig) -> Self {
355 Self { config }
356 }
357}
358
359impl Default for RollingHistoryPluginFactory {
360 fn default() -> Self {
361 Self::new(RollingHistoryConfig)
362 }
363}
364
365impl PluginFactory for RollingHistoryPluginFactory {
366 fn id(&self) -> &'static str {
367 ROLLING_HISTORY_PLUGIN_ID
368 }
369
370 fn build(&self, _ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
371 Ok(Arc::new(RollingHistoryPlugin {
372 config: self.config.clone(),
373 }))
374 }
375}
376
377struct RollingHistoryPlugin {
378 config: RollingHistoryConfig,
379}
380
381impl SessionPlugin for RollingHistoryPlugin {
382 fn id(&self) -> &'static str {
383 ROLLING_HISTORY_PLUGIN_ID
384 }
385
386 fn register(&self, reg: &mut PluginRegistrar) -> Result<(), PluginError> {
387 let config = self.config.clone();
388 reg.history()
389 .prepare_turn(100, Arc::new(RollingTurnTransform::new(config.clone())));
390 reg.history()
391 .rewrite(100, Arc::new(RollingHistoryRewriter::new(config)));
392 Ok(())
393 }
394}
395
396struct RollingTurnTransform;
397
398impl RollingTurnTransform {
399 fn new(_config: RollingHistoryConfig) -> Self {
400 Self
401 }
402}
403
404#[async_trait]
405impl TurnContextTransform for RollingTurnTransform {
406 fn id(&self) -> &'static str {
407 "rolling_history.prepare_turn"
408 }
409
410 async fn transform(
411 &self,
412 ctx: &TurnTransformContext<'_>,
413 mut input: PreparedContext,
414 ) -> Result<PreparedContext, HistoryError> {
415 let state = &ctx.state;
416 let prompt_usage = ctx.prompt_usage.as_ref();
417 let max_context_tokens = ctx.max_context_tokens;
418 let session_lifecycle = Arc::clone(&ctx.session_lifecycle);
419
420 let needs_pruning = pruning_needed(prompt_usage, max_context_tokens);
421 let needs_compaction = compaction_needed(prompt_usage, max_context_tokens);
422 if !needs_pruning && !needs_compaction {
423 return Ok(input);
424 }
425
426 let messages = input.messages.make_mut();
427
428 if needs_pruning {
429 prune_old_images(messages);
430 }
431
432 if !needs_compaction {
433 return Ok(input);
434 }
435
436 let messages = input.messages.as_slice();
437 let prefix_len = leading_system_prefix_len(messages);
438 let cut_point = find_compaction_cut_point(messages, prefix_len);
439 if cut_point <= prefix_len {
440 return Ok(input);
441 }
442
443 let prefix_messages = messages[prefix_len..cut_point].to_vec();
444 let Some(summary) = summarize_compaction_prefix(
445 &ctx.session_id,
446 &state.to_snapshot(),
447 prefix_messages,
448 None,
449 session_lifecycle,
450 ctx.scoped_effect_controller.clone(),
451 )
452 .await?
453 else {
454 return Ok(input);
455 };
456
457 input
458 .messages
459 .replace(apply_compaction_summary(messages, &summary, cut_point));
460 Ok(input)
461 }
462}
463
464struct RollingHistoryRewriter;
465
466impl RollingHistoryRewriter {
467 fn new(_config: RollingHistoryConfig) -> Self {
468 Self
469 }
470}
471
472#[async_trait]
473impl HistoryRewriter for RollingHistoryRewriter {
474 fn id(&self) -> &'static str {
475 "rolling_history.rewrite"
476 }
477
478 async fn rewrite(
479 &self,
480 ctx: &RewriteContext<'_>,
481 mut input: HistoryState,
482 ) -> Result<HistoryState, HistoryError> {
483 let session_id = ctx.session_id.clone();
484 let session_lifecycle = Arc::clone(&ctx.session_lifecycle);
485 let scoped_effect_controller = ctx.scoped_effect_controller.clone();
486
487 match &ctx.trigger {
488 RewriteTrigger::Manual { instructions } => {
489 if let Some(compacted) = compact_messages_core(
490 &session_id,
491 &ctx.state.to_snapshot(),
492 &input.messages,
493 instructions.as_deref(),
494 session_lifecycle.clone(),
495 scoped_effect_controller.clone(),
496 )
497 .await?
498 {
499 input.metadata.produced_summary = true;
500 input.metadata.pruned_message_count =
501 input.messages.len().saturating_sub(compacted.len()) as u32;
502 input.messages = compacted;
503 }
504 Ok(input)
505 }
506 RewriteTrigger::WindowShrink { .. } => {
507 if !compaction_needed(
508 ctx.state.last_prompt_usage(),
509 Some(ctx.state.policy().context_window_tokens()),
510 ) {
511 return Ok(input);
512 }
513 if let Some(compacted) = compact_messages_core(
514 &session_id,
515 &ctx.state.to_snapshot(),
516 &input.messages,
517 None,
518 session_lifecycle,
519 scoped_effect_controller,
520 )
521 .await?
522 {
523 input.metadata.produced_summary = true;
524 input.messages = compacted;
525 }
526 Ok(input)
527 }
528 RewriteTrigger::Periodic => Ok(input),
529 }
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use lash_core::SessionPolicy;
537 use serde_json::json;
538
539 fn text_message(id: &str, role: MessageRole, content: &str) -> Message {
540 Message {
541 id: id.to_string(),
542 role,
543 parts: vec![Part {
544 id: format!("{id}.p0"),
545 kind: PartKind::Text,
546 content: content.to_string(),
547 attachment: None,
548 tool_call_id: None,
549 tool_name: None,
550 tool_replay: None,
551 prune_state: lash_core::PruneState::Intact,
552 reasoning_meta: None,
553 response_meta: None,
554 }]
555 .into(),
556 origin: None,
557 }
558 }
559
560 fn image_message(id: &str, role: MessageRole, bytes: &[u8]) -> Message {
561 Message {
562 id: id.to_string(),
563 role,
564 parts: vec![Part {
565 id: format!("{id}.p0"),
566 kind: PartKind::Image,
567 content: String::new(),
568 attachment: Some(lash_core::session_model::message::PartAttachment {
569 reference: lash_core::AttachmentRef {
570 id: lash_core::AttachmentId::new(format!("{id}-att")),
571 media_type: lash_core::MediaType::Image(lash_core::ImageMediaType::Png),
572 byte_len: bytes.len() as u64,
573 width: None,
574 height: None,
575 label: None,
576 },
577 }),
578 tool_call_id: None,
579 tool_name: None,
580 tool_replay: None,
581 prune_state: lash_core::PruneState::Intact,
582 reasoning_meta: None,
583 response_meta: None,
584 }]
585 .into(),
586 origin: None,
587 }
588 }
589
590 use lash_core::testing::{MockSessionManager, mock_assembled_turn as empty_turn};
591
592 fn mock_manager() -> MockSessionManager {
593 MockSessionManager::default()
594 .with_tool_catalog(vec![
595 json!({"name":"exec_command"}),
596 json!({"name":"read_file"}),
597 ])
598 .with_turn(empty_turn("root", "Compacted work summary"))
599 }
600
601 fn build_turn_ctx(
602 session_id: &str,
603 state: SessionSnapshot,
604 prompt_usage: Option<PromptUsage>,
605 max_context_tokens: Option<usize>,
606 manager: Arc<MockSessionManager>,
607 ) -> TurnTransformContext<'static> {
608 TurnTransformContext {
609 session_id: session_id.to_string(),
610 state: state.read_view(),
611 prompt_usage,
612 max_context_tokens,
613 sessions: manager.clone(),
614 session_lifecycle: manager.clone(),
615 session_graph: manager,
616 scoped_effect_controller: lash_core::ScopedEffectController::shared(
617 Arc::new(lash_core::InlineRuntimeEffectController),
618 lash_core::EffectScope::turn(session_id, "rolling-history-test-turn"),
619 )
620 .expect("test scoped effect controller"),
621 direct_completions: lash_core::DirectCompletionClient::from_fn(|_, _| {
622 Err(lash_core::PluginError::Session(
623 "direct completions are unavailable in rolling history tests".to_string(),
624 ))
625 }),
626 }
627 }
628
629 #[tokio::test]
630 async fn rolling_turn_transform_strips_old_image_attachments() {
631 let messages = vec![
632 image_message("u0", MessageRole::User, &[1, 2, 3]),
633 text_message("u1", MessageRole::User, "recent"),
634 text_message("u2", MessageRole::User, "latest"),
635 ];
636
637 let state = SessionSnapshot::default();
638 let manager = Arc::new(mock_manager());
639 let transform = RollingTurnTransform::new(RollingHistoryConfig);
640 let ctx = build_turn_ctx(
641 "root",
642 state,
643 Some(PromptUsage {
644 prompt_context_tokens: 130_000,
645 input_tokens: 130_000,
646 cached_input_tokens: 0,
647 context_budget_tokens: 130_000,
648 }),
649 Some(200_000),
650 manager,
651 );
652 let prepared = PreparedContext {
653 messages: messages.into(),
654 ..Default::default()
655 };
656 let built = transform
657 .transform(&ctx, prepared)
658 .await
659 .expect("transform")
660 .messages;
661
662 let image_part = built[0].parts.first().expect("image part");
663 assert!(matches!(image_part.kind, PartKind::Image));
664 assert!(image_part.attachment.is_none());
665 assert_eq!(image_part.content, PRUNED_IMAGE_PLACEHOLDER);
666 }
667
668 #[tokio::test]
669 async fn rolling_turn_transform_replaces_prefix_with_summary() {
670 let manager = Arc::new(mock_manager());
671 let transform = RollingTurnTransform::new(RollingHistoryConfig);
672 let state = SessionSnapshot {
673 session_id: "root".to_string(),
674 policy: SessionPolicy::default(),
675 ..Default::default()
676 };
677 let ctx = build_turn_ctx(
678 "root",
679 state,
680 Some(PromptUsage {
681 prompt_context_tokens: 90_000,
682 input_tokens: 90_000,
683 cached_input_tokens: 0,
684 context_budget_tokens: 90_000,
685 }),
686 Some(100_000),
687 manager.clone(),
688 );
689 let prepared = PreparedContext {
690 messages: vec![
691 text_message("u1", MessageRole::User, "old work"),
692 text_message("a1", MessageRole::Assistant, "assistant old"),
693 text_message("u2", MessageRole::User, "latest request"),
694 ]
695 .into(),
696 ..Default::default()
697 };
698 let built = transform
699 .transform(&ctx, prepared)
700 .await
701 .expect("transform")
702 .messages;
703
704 assert!(built.iter().any(|message| {
705 message
706 .parts
707 .iter()
708 .any(|part| part.content.contains("Compacted work summary"))
709 }));
710 assert!(built.iter().any(|message| {
711 message
712 .parts
713 .iter()
714 .any(|part| part.content.contains("latest request"))
715 }));
716 assert!(!built.iter().any(|message| {
717 message
718 .parts
719 .iter()
720 .any(|part| part.content.contains("old work"))
721 }));
722
723 let created = manager.created_snapshot();
724 assert_eq!(created.len(), 1);
725 let turns = manager.turns.lock().expect("turns lock").clone();
726 assert_eq!(turns.len(), 1);
727 assert_eq!(
728 turns[0].1,
729 "rolling-history-test-turn:rolling-history-compaction"
730 );
731 assert_eq!(
732 turns[0].2.as_deref(),
733 Some("rolling-history-test-turn:rolling-history-compaction")
734 );
735 assert_eq!(
736 turns[0].3,
737 lash_core::EffectScope::turn(
738 "root-compaction",
739 "rolling-history-test-turn:rolling-history-compaction"
740 )
741 );
742 }
743}