Skip to main content

lean_ctx/core/dashboard_pro/
effects.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Plans the runtime answers are checked against the request and the bound
3//! project before any of them runs, then executed with the engine's own code
4//! paths and read back. Anything unknown, outside the project, or not what
5//! the user asked for is a 409.
6
7use std::collections::{BTreeSet, HashMap};
8
9use base64::Engine as _;
10use chrono::{DateTime, Utc};
11use serde_json::{Value, json};
12
13use crate::core::knowledge::{ImportMerge, KnowledgeFact, ProjectKnowledge};
14
15use super::request::{BridgeRequest, Op, is_fact_ref, is_profile_name, is_safe_webhook};
16use super::state::ProState;
17use super::wire::{AlertRecord, AppliedItem, Baseline, FileOut, Plan, Profile, ProjectColumn};
18use super::{
19    GUARD_KINDS, ProError, ProResult, ProjectScope, collect, export, ledger, plain_text,
20    security_fields, snapshots,
21};
22
23/// At most this many plans in one answer.
24const MAX_PLANS: usize = 8;
25const SNAPSHOT_HINT: &str = "A snapshot was saved first; restore it under Memory Care → Snapshots.";
26
27/// Desktop and webhook delivery of alerts.
28pub trait Notifier: Send + Sync {
29    fn desktop(&self, text: &str) -> bool;
30    fn webhook(&self, url: &str, body: &[u8]) -> bool;
31}
32
33/// The engine's notifier: the native desktop notification and an https
34/// POST without redirects and with a timeout.
35pub struct SystemNotifier;
36
37impl Notifier for SystemNotifier {
38    fn desktop(&self, text: &str) -> bool {
39        crate::core::value::notify::send("LeanCTX", text)
40    }
41
42    fn webhook(&self, url: &str, body: &[u8]) -> bool {
43        if !is_safe_webhook(url) {
44            return false;
45        }
46        let timeout = Some(std::time::Duration::from_secs(6));
47        let agent =
48            crate::core::http_client::ureq_agent_without_redirects(timeout, timeout, timeout);
49        agent
50            .post(url)
51            .header("Content-Type", "application/json")
52            .send(body)
53            .is_ok_and(|r| r.status().is_success())
54    }
55}
56
57/// What one plan produced.
58pub(crate) enum Outcome {
59    Applied(Value),
60    File(FileOut),
61}
62
63pub(crate) struct Ctx<'a> {
64    pub(crate) scope: &'a ProjectScope,
65    pub(crate) request: &'a BridgeRequest,
66    pub(crate) now: DateTime<Utc>,
67    pub(crate) notifier: &'a dyn Notifier,
68}
69
70/// Typed plans the op allows, or 409.
71pub(crate) fn parse_plans(op: Op, raw: Vec<Value>) -> ProResult<Vec<Plan>> {
72    if raw.is_empty() || raw.len() > MAX_PLANS {
73        return Err(ProError::conflict("Unexpected number of planned changes"));
74    }
75    raw.into_iter()
76        .map(|value| {
77            let plan: Plan = serde_json::from_value(value)
78                .map_err(|_| ProError::conflict("Unknown planned change"))?;
79            if !op.allowed_effects().contains(&plan.effect()) {
80                return Err(ProError::conflict(
81                    "Planned change does not fit the request",
82                ));
83            }
84            Ok(plan)
85        })
86        .collect()
87}
88
89fn ensure(condition: bool, message: &str) -> ProResult<()> {
90    if condition {
91        Ok(())
92    } else {
93        Err(ProError::conflict(message))
94    }
95}
96
97fn is_time(value: &str) -> bool {
98    DateTime::parse_from_rfc3339(value).is_ok()
99}
100
101fn fact_ref(fact: &KnowledgeFact) -> String {
102    format!("{}/{}", fact.category, fact.key)
103}
104
105fn current_map(facts: &[KnowledgeFact]) -> HashMap<String, KnowledgeFact> {
106    facts
107        .iter()
108        .filter(|f| f.is_current())
109        .map(|f| (fact_ref(f), f.clone()))
110        .collect()
111}
112
113/// A restore against the live store: what comes back, what is reset.
114pub(crate) struct RestorePlan {
115    snapshot: HashMap<String, KnowledgeFact>,
116    now: HashMap<String, KnowledgeFact>,
117    pub(crate) missing: Vec<String>,
118    pub(crate) changed: Vec<String>,
119}
120
121pub(crate) fn restore_plan(scope: &ProjectScope, id: &str) -> ProResult<RestorePlan> {
122    let snapshot = current_map(&snapshots::read_facts(scope, id)?);
123    let records = collect::knowledge_records(scope);
124    let now = current_map(&records);
125    let mut missing: Vec<String> = snapshot
126        .keys()
127        .filter(|r| !now.contains_key(*r))
128        .cloned()
129        .collect();
130    let mut changed: Vec<String> = snapshot
131        .iter()
132        .filter(|(r, f)| now.get(*r).is_some_and(|n| n.value != f.value))
133        .map(|(r, _)| r.clone())
134        .collect();
135    missing.sort();
136    changed.sort();
137    // No room check: knowledge has no fact limit, so a restore never pushes
138    // other facts out.
139    Ok(RestorePlan {
140        snapshot,
141        now,
142        missing,
143        changed,
144    })
145}
146
147fn same_set(a: &[String], b: &[String]) -> bool {
148    a.iter().collect::<BTreeSet<_>>() == b.iter().collect::<BTreeSet<_>>()
149}
150
151fn requested_profile(ctx: &Ctx<'_>) -> Option<Profile> {
152    let name = ctx.request.b_str("name");
153    ProState::new(ctx.scope)
154        .profiles()
155        .into_iter()
156        .find(|p| p.name == name)
157}
158
159/// Checks one plan against the request and the project, without effects.
160pub(crate) fn validate(ctx: &Ctx<'_>, plan: &Plan) -> ProResult<()> {
161    let request = ctx.request;
162    let state = ProState::new(ctx.scope);
163    match plan {
164        Plan::AlertsSettings { settings } => {
165            let stored = state.alert_settings();
166            let wanted = |key: &str| request.body.get(key);
167            let kinds: Vec<Value> = settings.kinds.iter().map(|k| json!(k)).collect();
168            ensure(
169                settings
170                    .kinds
171                    .iter()
172                    .all(|k| GUARD_KINDS.contains(&k.as_str()))
173                    && settings.kinds.iter().collect::<BTreeSet<_>>().len() == settings.kinds.len(),
174                "Alert kinds outside the guard list",
175            )?;
176            ensure(
177                settings.webhook.is_empty() || is_safe_webhook(&settings.webhook),
178                "Unsafe webhook",
179            )?;
180            let matches = |key: &str, planned: Value, stored: Value| {
181                wanted(key).map_or(planned == stored, |w| *w == planned)
182            };
183            ensure(
184                matches("enabled", json!(settings.enabled), json!(stored.enabled))
185                    && matches("desktop", json!(settings.desktop), json!(stored.desktop))
186                    && matches("webhook", json!(settings.webhook), json!(stored.webhook))
187                    && matches("kinds", Value::Array(kinds), json!(stored.kinds)),
188                "Alert settings differ from the request",
189            )
190        }
191        Plan::AlertsNotify {
192            at,
193            text,
194            counts,
195            test,
196            desktop,
197            webhook,
198        } => {
199            let stored = state.alert_settings();
200            ensure(*test == (request.op == Op::AlertsTest), "Unexpected alert")?;
201            ensure(*test || stored.enabled, "Alerts are off")?;
202            ensure(is_time(at) && plain_text(text, 240), "Invalid alert text")?;
203            ensure(
204                counts.iter().all(|(k, n)| {
205                    GUARD_KINDS.contains(&k.as_str()) && (0..=1_000_000_000).contains(n)
206                }),
207                "Alert counts outside the guard list",
208            )?;
209            ensure(
210                *desktop == stored.desktop && *webhook == stored.webhook,
211                "Alert channels differ from the settings",
212            )
213        }
214        Plan::ProfilesPut {
215            name,
216            values,
217            saved_at,
218        } => {
219            ensure(
220                request.b_str("action") == "save" && name == request.b_str("name"),
221                "Profile differs from the request",
222            )?;
223            ensure(
224                is_profile_name(name) && is_time(saved_at),
225                "Invalid profile",
226            )?;
227            for (key, value) in values {
228                let field = security_fields::lookup(key)
229                    .ok_or_else(|| ProError::conflict("Profile holds an unmanaged setting"))?;
230                security_fields::wire_of(field, value)?;
231            }
232            Ok(())
233        }
234        Plan::ProfilesDelete { name } => ensure(
235            request.b_str("action") == "delete" && name == request.b_str("name"),
236            "Profile differs from the request",
237        ),
238        Plan::ConfigSet {
239            key,
240            wire,
241            expected,
242            weakening,
243        } => {
244            ensure(
245                request.b_str("action") == "apply",
246                "Unexpected setting change",
247            )?;
248            let field = security_fields::lookup(key)
249                .ok_or_else(|| ProError::conflict("This setting is not managed here"))?;
250            ensure(
251                security_fields::wire_of(field, expected)? == *wire
252                    && security_fields::expected_of(field, wire) == *expected,
253                "Setting value does not match its wire form",
254            )?;
255            let profile =
256                requested_profile(ctx).ok_or_else(|| ProError::not_found("Unknown profile"))?;
257            let stored = profile
258                .values
259                .get(key)
260                .map(|v| security_fields::wire_of(field, v))
261                .transpose()?;
262            ensure(
263                stored.as_deref() == Some(wire.as_str()),
264                "Setting is not part of the profile",
265            )?;
266            let before = security_fields::effective(key).unwrap_or_default();
267            let lowers = security_fields::weakening(field, &before, wire);
268            if lowers && !request.b_true("confirm_weaker") {
269                return Err(ProError::conflict(
270                    "This lowers protection. Confirm the change to continue.",
271                ));
272            }
273            ensure(lowers == *weakening, "Protection change misreported")
274        }
275        Plan::MemorySnapshot { reason } => ensure(
276            reason == request.b_str("reason"),
277            "Snapshot differs from the request",
278        ),
279        Plan::MemoryRestore {
280            id,
281            missing,
282            changed,
283        } => {
284            ensure(
285                id == request.b_str("id"),
286                "Snapshot differs from the request",
287            )?;
288            let plan = restore_plan(ctx.scope, id)?;
289            ensure(
290                same_set(missing, &plan.missing) && same_set(changed, &plan.changed),
291                "Memory changed; compare the snapshot again",
292            )
293        }
294        Plan::MemoryForget { refs, reason } => {
295            let asked: Vec<String> = request
296                .body
297                .get("refs")
298                .and_then(Value::as_array)
299                .map(|a| {
300                    a.iter()
301                        .filter_map(Value::as_str)
302                        .map(str::to_string)
303                        .collect()
304                })
305                .unwrap_or_default();
306            ensure(
307                same_set(refs, &asked) && reason == request.b_str("reason"),
308                "Cleanup differs from the request",
309            )?;
310            ensure(refs.iter().all(|r| is_fact_ref(r)), "Invalid insight key")?;
311            let current = current_map(&collect::knowledge_records(ctx.scope));
312            ensure(
313                refs.iter().all(|r| current.contains_key(r)),
314                "Memory changed; refresh before cleaning up",
315            )
316        }
317        Plan::MemoryConsolidate { run } => ensure(
318            *run && request.b_true("run"),
319            "Consolidation differs from the request",
320        ),
321        Plan::ContextOverlay { action, path } => {
322            ensure(
323                action == request.b_str("action") && path == request.b_str("path"),
324                "Rule differs from the request",
325            )?;
326            ensure(
327                ctx.scope.jailed(path)?.is_file(),
328                "The path is not a file of this project",
329            )?;
330            ensure(
331                collect::heatmap(ctx.scope).contains_key(path),
332                "The path is not a current recommendation",
333            )
334        }
335        Plan::AdaptiveRecord { item } => ensure(
336            item.path == request.b_str("path") && item.action == request.b_str("action"),
337            "Record differs from the request",
338        ),
339        Plan::AdaptiveDismiss { path } => {
340            ensure(
341                path == request.b_str("path"),
342                "Dismissal differs from the request",
343            )?;
344            ctx.scope.jailed(path).map(|_| ())
345        }
346        Plan::LedgerExport {
347            format,
348            file_name,
349            mime,
350            filters,
351            project_column,
352        } => {
353            ensure(format == request.q("format"), "Export format differs")?;
354            ensure(
355                *file_name == export::file_name(ctx.now.date_naive(), format)
356                    && mime == export::mime_of(format),
357                "Export file differs",
358            )?;
359            let mine = ledger::filters(ctx.scope, &ctx.request.query)?;
360            let normalize = |f: &super::wire::LedgerFilters| {
361                let mut f = f.clone();
362                if let Some(scope) = f.scope.as_mut() {
363                    scope.sort();
364                    scope.dedup();
365                }
366                f
367            };
368            ensure(
369                normalize(filters) == normalize(&mine),
370                "Export filters differ",
371            )?;
372            let labels = ctx.scope.labels();
373            ensure(
374                match project_column {
375                    ProjectColumn::Fixed { name } => *name == ctx.scope.label,
376                    ProjectColumn::Labels { labels: planned } => {
377                        planned.iter().all(|l| labels.contains(l))
378                    }
379                },
380                "Export project column outside this project",
381            )
382        }
383    }
384}
385
386fn snapshot_now(ctx: &Ctx<'_>, reason: &str) -> ProResult<super::wire::SnapshotMeta> {
387    let knowledge = ProjectKnowledge::load_or_create(&ctx.scope.root);
388    snapshots::create(ctx.scope, &knowledge, reason, ctx.now)
389}
390
391fn policy() -> ProResult<crate::core::memory_policy::MemoryPolicy> {
392    crate::core::config::Config::load()
393        .memory_policy_effective()
394        .map_err(|_| ProError::internal("Invalid memory policy"))
395}
396
397fn import(ctx: &Ctx<'_>, facts: Vec<KnowledgeFact>, merge: ImportMerge) -> ProResult<()> {
398    if facts.is_empty() {
399        return Ok(());
400    }
401    ProjectKnowledge::mutate_locked(&ctx.scope.root, |k| {
402        k.import_facts(facts, merge, "dashboard-pro");
403    })
404    .map(|_| ())
405    .map_err(|_| ProError::internal(format!("The restore was not saved. {SNAPSHOT_HINT}")))
406}
407
408/// Executes one validated plan and reads it back.
409pub(crate) fn execute(ctx: &Ctx<'_>, plan: Plan) -> ProResult<Outcome> {
410    let state = ProState::new(ctx.scope);
411    let effect = plan.effect();
412    let applied = |readback: Value| {
413        Ok(Outcome::Applied(
414            json!({ "effect": effect, "readback": readback }),
415        ))
416    };
417    match plan {
418        Plan::AlertsSettings { settings } => {
419            state.put_alert_settings(&settings)?;
420            let back = state.alert_settings();
421            ensure(back == settings, "Alert settings did not read back")?;
422            applied(json!({ "settings": back }))
423        }
424        Plan::AlertsNotify {
425            at,
426            text,
427            counts,
428            test,
429            desktop,
430            webhook,
431        } => {
432            let mut channels = Vec::new();
433            if desktop {
434                channels.push(
435                    if ctx.notifier.desktop(&text) {
436                        "desktop"
437                    } else {
438                        "desktop failed"
439                    }
440                    .to_string(),
441                );
442            }
443            if !webhook.is_empty() {
444                // Counts only: never content, paths or project names.
445                let body = json!({ "source": "leanctx", "test": test, "counts": counts, "at": at });
446                let sent = ctx.notifier.webhook(&webhook, body.to_string().as_bytes());
447                channels.push(if sent { "webhook" } else { "webhook failed" }.to_string());
448            }
449            let record = AlertRecord {
450                at,
451                text,
452                channels,
453                test,
454            };
455            state.append_alert(record.clone())?;
456            ensure(
457                state.alerts_file().history.last() == Some(&record),
458                "Alert history did not read back",
459            )?;
460            applied(json!({ "alert": record }))
461        }
462        Plan::ProfilesPut {
463            name,
464            values,
465            saved_at,
466        } => {
467            let profile = Profile {
468                name: name.clone(),
469                values,
470                saved_at,
471            };
472            state.put_profile(profile.clone())?;
473            ensure(
474                state.profiles().contains(&profile),
475                "Profile did not read back",
476            )?;
477            applied(json!({ "profiles": state.profiles() }))
478        }
479        Plan::ProfilesDelete { name } => {
480            state.delete_profile(&name)?;
481            ensure(
482                !state.profiles().iter().any(|p| p.name == name),
483                "Profile was not deleted",
484            )?;
485            applied(json!({ "profiles": state.profiles() }))
486        }
487        Plan::ConfigSet {
488            key,
489            wire,
490            expected,
491            weakening,
492        } => {
493            crate::core::config::setter::set_by_key(&key, &wire)
494                .map_err(|e| ProError::conflict(format!("The setting was refused: {e}")))?;
495            let field = security_fields::lookup(&key)
496                .ok_or_else(|| ProError::conflict("This setting is not managed here"))?;
497            let after =
498                security_fields::effective(&key).map(|w| security_fields::expected_of(field, &w));
499            ensure(
500                after.as_ref() == Some(&expected),
501                "The instance did not report the new value. A setting in this project's own configuration may take precedence over the device setting.",
502            )?;
503            applied(json!({ "key": key, "value": expected, "weakened": weakening }))
504        }
505        Plan::MemorySnapshot { reason } => {
506            let meta = snapshot_now(ctx, &reason)?;
507            ensure(
508                snapshots::list(ctx.scope, snapshots::LIST_LIMIT)
509                    .iter()
510                    .any(|m| m.id == meta.id),
511                "Snapshot did not read back",
512            )?;
513            applied(json!({ "snapshot": meta }))
514        }
515        Plan::MemoryRestore { id, .. } => {
516            let plan = restore_plan(ctx.scope, &id)?;
517            let safety = snapshot_now(ctx, "Before restoring a snapshot")?;
518            let pick = |refs: &[String]| -> Vec<KnowledgeFact> {
519                refs.iter()
520                    .filter_map(|r| plan.snapshot.get(r).cloned())
521                    .collect()
522            };
523            import(ctx, pick(&plan.missing), ImportMerge::SkipExisting)?;
524            import(ctx, pick(&plan.changed), ImportMerge::Replace)?;
525            let after = current_map(&collect::knowledge_records(ctx.scope));
526            let lost = plan.now.keys().any(|r| !after.contains_key(r));
527            let differs = plan
528                .snapshot
529                .iter()
530                .any(|(r, f)| after.get(r).is_none_or(|a| a.value != f.value));
531            if lost || differs {
532                return Err(ProError::conflict(format!(
533                    "The restore did not read back completely. {SNAPSHOT_HINT}"
534                )));
535            }
536            applied(json!({
537                "restored": plan.missing.len(),
538                "updated": plan.changed.len(),
539                "insights": after.len(),
540                "safety_snapshot": safety.id,
541            }))
542        }
543        Plan::MemoryForget { refs, reason } => {
544            let safety = snapshot_now(ctx, &reason)?;
545            let policy = policy()?;
546            let pairs: Vec<(String, String)> = refs
547                .iter()
548                .filter_map(|r| r.split_once('/'))
549                .map(|(c, k)| (c.to_string(), k.to_string()))
550                .collect();
551            ProjectKnowledge::mutate_locked(&ctx.scope.root, |k| {
552                let mut removed = false;
553                for (category, key) in &pairs {
554                    removed |= k.remove_fact(category, key);
555                }
556                if removed {
557                    let _ = k.run_memory_lifecycle(&policy);
558                }
559            })
560            .map_err(|_| {
561                ProError::internal(format!("The removal was not saved. {SNAPSHOT_HINT}"))
562            })?;
563            let left = current_map(&collect::knowledge_records(ctx.scope));
564            if refs.iter().any(|r| left.contains_key(r)) {
565                return Err(ProError::conflict(format!(
566                    "Some insights were not removed. {SNAPSHOT_HINT}"
567                )));
568            }
569            applied(json!({ "removed": refs.len(), "safety_snapshot": safety.id }))
570        }
571        Plan::MemoryConsolidate { .. } => {
572            let safety = snapshot_now(ctx, "Before consolidation")?;
573            let report = crate::core::knowledge_consolidate::consolidate_project_knowledge_with(
574                &ctx.scope.root,
575                &crate::core::consolidation_engine::ConsolidateOptions::manual(),
576            )
577            .map_err(|_| ProError::internal(format!("Consolidation failed. {SNAPSHOT_HINT}")))?;
578            let text = crate::core::knowledge_consolidate::format_consolidation_report(&report);
579            applied(json!({
580                "summary": consolidation_summary(&text),
581                "safety_snapshot": safety.id,
582            }))
583        }
584        Plan::ContextOverlay { action, path } => {
585            use crate::core::context_overlay::{
586                ContextOverlay, OverlayAuthor, OverlayOp, OverlayScope, OverlayStore,
587            };
588            let root = std::path::PathBuf::from(&ctx.scope.root);
589            let target = crate::core::context_field::ContextItemId::from_file(&path);
590            let operation = if action == "compress" {
591                OverlayOp::SetView(crate::core::context_field::ViewKind::Map)
592            } else {
593                OverlayOp::Exclude {
594                    reason: "adaptive recommendation".into(),
595                }
596            };
597            let mut store = OverlayStore::load_project(&root);
598            store.add(ContextOverlay::new(
599                target.clone(),
600                operation.clone(),
601                OverlayScope::Project,
602                String::new(),
603                OverlayAuthor::User,
604            ));
605            store
606                .save_project(&root)
607                .map_err(|_| ProError::internal("The rule was not saved"))?;
608            let back = OverlayStore::load_project(&root);
609            ensure(
610                back.all()
611                    .iter()
612                    .any(|o| o.target == target && o.operation == operation),
613                "The rule did not read back",
614            )?;
615            applied(json!({ "action": action, "path": path }))
616        }
617        Plan::AdaptiveRecord { item } => {
618            let heat: super::wire::HeatmapEntry = collect::heatmap(ctx.scope)
619                .remove(&item.path)
620                .and_then(|v| serde_json::from_value(v).ok())
621                .unwrap_or_default();
622            let original = heat.total_original_tokens.unwrap_or(0);
623            let saved = heat.total_tokens_saved.unwrap_or(0);
624            let record = AppliedItem {
625                path: item.path,
626                action: item.action,
627                applied_at: ctx.now.to_rfc3339(),
628                baseline: Baseline {
629                    reads: heat.access_count.unwrap_or(0),
630                    original,
631                    saved,
632                    ratio: (original > 0)
633                        .then(|| ((saved as f64 / original as f64) * 1000.0).round() / 1000.0),
634                },
635            };
636            let mut adaptive = state.adaptive();
637            adaptive.applied.push(record.clone());
638            state.put_adaptive(adaptive)?;
639            ensure(
640                state.adaptive().applied.last() == Some(&record),
641                "The record did not read back",
642            )?;
643            applied(json!({ "item": record }))
644        }
645        Plan::AdaptiveDismiss { path } => {
646            let mut adaptive = state.adaptive();
647            if !adaptive.dismissed.contains(&path) {
648                adaptive.dismissed.push(path.clone());
649            }
650            // A dismissed file is no longer an applied map-view recommendation.
651            adaptive
652                .applied
653                .retain(|item| !(item.path == path && item.action == "compress"));
654            state.put_adaptive(adaptive)?;
655            let back = state.adaptive();
656            ensure(
657                back.dismissed.contains(&path)
658                    && !back
659                        .applied
660                        .iter()
661                        .any(|item| item.path == path && item.action == "compress"),
662                "The dismissal did not read back",
663            )?;
664            applied(json!({ "path": path }))
665        }
666        Plan::LedgerExport {
667            format,
668            file_name,
669            mime,
670            filters,
671            project_column,
672        } => {
673            let bytes = export::build(ctx.scope, &filters, &format, &project_column)?;
674            Ok(Outcome::File(FileOut {
675                name: file_name,
676                mime,
677                bytes_b64: base64::engine::general_purpose::STANDARD.encode(bytes),
678            }))
679        }
680    }
681}
682
683fn plural(count: i64, word: &str) -> String {
684    format!("{count} {word}{}", if count == 1 { "" } else { "s" })
685}
686
687/// Plain-English summary of a real consolidation run's report.
688pub(crate) fn consolidation_summary(output: &str) -> Value {
689    let number = |pattern: &str| -> i64 {
690        regex::Regex::new(pattern)
691            .ok()
692            .and_then(|re| re.captures(output))
693            .and_then(|c| c.get(1))
694            .and_then(|m| m.as_str().parse().ok())
695            .unwrap_or(0)
696    };
697    let merged = number(r"consolidated (\d+)");
698    let archived = number(r"archived-to-target (\d+)")
699        .max(number(r"Lifecycle:[^\n]*?archived (\d+)"))
700        + number(r"Patterns:[^\n]*?compacted (\d+)")
701        + number(r"History:[^\n]*?compacted (\d+)");
702    let kept = match number(r"remaining (\d+)") {
703        0 => number(r"Facts: (\d+) active"),
704        n => n,
705    };
706    let session = number(r"Session import: \S+ \((\d+) item");
707    let mut lines = Vec::new();
708    if merged > 0 {
709        lines.push(format!(
710            "{} were merged into one.",
711            plural(merged, "similar insight")
712        ));
713    }
714    if archived > 0 {
715        lines.push(format!(
716            "{} were archived; archived items can be restored.",
717            plural(archived, "item")
718        ));
719    }
720    if session > 0 {
721        lines.push(format!(
722            "{} from the current session were added.",
723            plural(session, "item")
724        ));
725    }
726    if lines.is_empty() {
727        lines.push("Nothing needed consolidating.".into());
728    }
729    lines.push(format!("{} stayed in memory.", plural(kept, "insight")));
730    json!({ "merged": merged, "archived": archived, "kept": kept, "preview": false, "lines": lines })
731}
732
733/// Checks every plan first, then runs them in order.
734pub(crate) fn apply(ctx: &Ctx<'_>, plans: Vec<Plan>) -> ProResult<Vec<Outcome>> {
735    for plan in &plans {
736        validate(ctx, plan)?;
737    }
738    plans.into_iter().map(|plan| execute(ctx, plan)).collect()
739}