Skip to main content

lean_ctx_server/tools/ctx_knowledge/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2use chrono::Utc;
3
4#[cfg(feature = "embeddings")]
5use lean_ctx::core::embeddings::EmbeddingEngine;
6
7use lean_ctx::core::consolidation_engine::ConsolidateOptions;
8use lean_ctx::core::knowledge::ProjectKnowledge;
9use lean_ctx::core::memory_policy::MemoryPolicy;
10use lean_ctx::core::session::SessionState;
11mod changes;
12pub(crate) use changes::session_change_count;
13pub(crate) mod embeddings;
14pub(crate) use embeddings::*;
15pub mod fact_ids;
16pub mod freshness;
17mod linked;
18mod remember;
19pub use remember::*;
20mod restore;
21pub use restore::{DEFAULT_RESTORE_LIMIT, RestoreOptions, format_restore_report, run_restore};
22mod search;
23pub(crate) use search::*;
24
25mod consolidate;
26pub use consolidate::*;
27#[cfg(test)]
28mod engine_tests;
29#[cfg(test)]
30mod hive_guard_tests;
31#[cfg(test)]
32mod tests;
33
34/// Dispatches knowledge base actions (remember, recall, pattern, timeline, etc.).
35#[allow(clippy::too_many_arguments)]
36pub fn handle(
37    project_root: &str,
38    action: &str,
39    category: Option<&str>,
40    key: Option<&str>,
41    value: Option<&str>,
42    query: Option<&str>,
43    session_id: &str,
44    pattern_type: Option<&str>,
45    examples: Option<Vec<String>>,
46    confidence: Option<f32>,
47    mode: Option<&str>,
48    as_of: Option<&str>,
49) -> String {
50    match handle_outcome(
51        project_root,
52        action,
53        category,
54        key,
55        value,
56        query,
57        session_id,
58        pattern_type,
59        examples,
60        confidence,
61        mode,
62        as_of,
63    ) {
64        Ok(text) | Err(text) => text,
65    }
66}
67
68/// [`handle`] with a typed outcome: `Ok` is the reply, `Err` is the text of a
69/// failed call — the same text [`handle`] answers, after the same gates.
70///
71/// Typed as failures are the refusals every action shares (knowledge
72/// protection unavailable, output withheld by policy, recovered text not
73/// admitted) and every failure of `recall`, `timeline` and `search` — the
74/// actions that declare every source
75/// (`CtxKnowledgeTool::publishes_every_source`), whose error text must never
76/// be recorded as a delivery of the sources captured before the failure. The
77/// other actions still answer their own failures as reply text.
78#[allow(clippy::too_many_arguments)]
79pub fn handle_outcome(
80    project_root: &str,
81    action: &str,
82    category: Option<&str>,
83    key: Option<&str>,
84    value: Option<&str>,
85    query: Option<&str>,
86    session_id: &str,
87    pattern_type: Option<&str>,
88    examples: Option<Vec<String>>,
89    confidence: Option<f32>,
90    mode: Option<&str>,
91    as_of: Option<&str>,
92) -> Result<String, String> {
93    if let Err(error) = lean_ctx::core::knowledge::protection::current(project_root) {
94        return Err(format!("Error: {error}"));
95    }
96    lean_ctx::core::policy::runtime::REQUEST_PROJECT.sync_scope(
97        std::cell::RefCell::new(Some(std::path::PathBuf::from(project_root))),
98        || {
99    let declared = match action {
100        "recall" => Some(handle_recall(project_root, category, query, session_id, mode, as_of)),
101        "timeline" => Some(handle_timeline(project_root, category)),
102        "search" => Some(handle_search(query, session_id)),
103        _ => None,
104    };
105    let failed = matches!(declared, Some(Err(_)));
106    let result = match declared {
107        Some(Ok(text) | Err(text)) => text,
108        None => match action {
109        "policy" => handle_policy(value),
110        "remember" => handle_remember(project_root, category, key, value, session_id, confidence),
111        "pattern" => handle_pattern(project_root, pattern_type, value, examples, session_id),
112        "feedback" => handle_feedback(project_root, category, key, value, session_id),
113        "relate" => crate::tools::ctx_knowledge_relations::handle_relate(
114            project_root,
115            category,
116            key,
117            value,
118            query,
119            session_id,
120        ),
121        "unrelate" => crate::tools::ctx_knowledge_relations::handle_unrelate(
122            project_root,
123            category,
124            key,
125            value,
126            query,
127        ),
128        "relations" => crate::tools::ctx_knowledge_relations::handle_relations(
129            project_root,
130            category,
131            key,
132            value,
133            query,
134        ),
135        "relations_diagram" => crate::tools::ctx_knowledge_relations::handle_relations_diagram(
136            project_root,
137            category,
138            key,
139            value,
140            query,
141        ),
142        "status" => handle_status(project_root),
143        "changes" => changes::handle_changes(project_root, query, session_id),
144        "repin" => changes::handle_repin(project_root, category, key),
145        "coverage" => changes::handle_coverage(project_root),
146        "health" => handle_health(project_root),
147        "lifecycle_report" => handle_lifecycle_report(project_root),
148        "remove" => handle_remove(project_root, category, key),
149        "export" => handle_export(project_root),
150        "consolidate" => handle_consolidate(project_root),
151        "consolidate_preview" => handle_consolidate_preview(project_root),
152        "restore" => handle_restore(project_root, category, query, None),
153        "rooms" => handle_rooms(project_root),
154        "wakeup" => handle_wakeup(project_root),
155        "embeddings_status" => handle_embeddings_status(project_root),
156        "embeddings_reset" => handle_embeddings_reset(project_root),
157        "embeddings_reindex" => handle_embeddings_reindex(project_root),
158        "judge" => handle_judge(project_root, category, key, value, query),
159        // Deprecated no-op (rust/data/DEPRECATIONS.toml): accepted until removal.
160        "cognition_loop" => "ctx_knowledge action=cognition_loop is retired and has no effect: scheduled consolidation already runs the memory lifecycle (decay, merge, archival). Use action=consolidate to run it now.".to_string(),
161        "bridge_publish" => handle_bridge_publish(project_root, session_id),
162        "bridge_pull" => handle_bridge_pull(project_root, session_id),
163        "bridge_status" => handle_bridge_status(project_root),
164        _ => format!(
165            "Unknown action: {action}. Use: policy, remember, recall, pattern, feedback, judge, relate, unrelate, relations, relations_diagram, status, health, lifecycle_report, remove, export, consolidate, consolidate_preview, restore, timeline, rooms, search, wakeup, embeddings_status, embeddings_reset, embeddings_reindex, bridge_publish, bridge_pull, bridge_status"
166        ),
167        },
168    };
169    let Ok(result) = lean_ctx::core::policy::content::protect_active(&result) else {
170        return Err("Error: knowledge output withheld by current policy".to_string());
171    };
172    // Stored knowledge is recovered text: every view of it passes the context
173    // gateway under the current policy, including facts written before the
174    // store admitted its input (G5). A failure text passes the same gates.
175    match lean_ctx::core::context_admission::recovery::admit_recovered(&result, "knowledge") {
176        Ok(text) if failed => Err(text),
177        Ok(text) if action == "wakeup" => Ok(wakeup_delta(value, text)),
178        Ok(text) => Ok(text),
179        Err(error) => Err(format!("Error: {error}")),
180    }
181        },
182    )
183}
184
185fn handle_policy(value: Option<&str>) -> String {
186    let sub = value.unwrap_or("show").trim().to_lowercase();
187    let profile = lean_ctx::core::profiles::active_profile_name();
188
189    match sub.as_str() {
190        "show" => {
191            let policy = match load_policy_or_error() {
192                Ok(p) => p,
193                Err(e) => return e,
194            };
195
196            let cfg_path = lean_ctx::core::config::Config::path().map_or_else(
197                || "~/.lean-ctx/config.toml".to_string(),
198                |p| p.display().to_string(),
199            );
200
201            format!(
202                "Knowledge policy (effective, profile={profile}):\n\
203                 - memory.knowledge facts: no limit (max_facts is ignored)\n\
204                 - memory.knowledge.contradiction_threshold={}\n\
205                 - memory.knowledge.recall_facts_limit={}\n\
206                 - memory.knowledge.rooms_limit={}\n\
207                 - memory.knowledge.timeline_limit={}\n\
208                 - memory.knowledge.relations_limit={}\n\
209                 - memory.lifecycle.decay_rate={}\n\
210                 - memory.lifecycle.stale_days={}\n\
211                 \nConfig: {cfg_path}",
212                policy.knowledge.contradiction_threshold,
213                policy.knowledge.recall_facts_limit,
214                policy.knowledge.rooms_limit,
215                policy.knowledge.timeline_limit,
216                policy.knowledge.relations_limit,
217                policy.lifecycle.decay_rate,
218                policy.lifecycle.stale_days
219            )
220        }
221        "validate" => match load_policy_or_error() {
222            Ok(_) => format!("OK: memory policy valid (profile={profile})"),
223            Err(e) => e,
224        },
225        _ => "Error: policy value must be show|validate".to_string(),
226    }
227}
228
229fn handle_feedback(
230    project_root: &str,
231    category: Option<&str>,
232    key: Option<&str>,
233    value: Option<&str>,
234    session_id: &str,
235) -> String {
236    let Some(cat) = category else {
237        return "Error: category is required for feedback".to_string();
238    };
239    let Some(k) = key else {
240        return "Error: key is required for feedback".to_string();
241    };
242    let dir = value.unwrap_or("up").trim().to_lowercase();
243    let is_up = matches!(dir.as_str(), "up" | "+1" | "+" | "true" | "1");
244    let is_down = matches!(dir.as_str(), "down" | "-1" | "-" | "false" | "0");
245    if !is_up && !is_down {
246        return "Error: feedback value must be up|down (+1|-1)".to_string();
247    }
248
249    // Read-modify-write under the cross-process lock (#326/#594) so concurrent
250    // CLI/daemon/MCP feedback never clobbers each other.
251    let outcome = ProjectKnowledge::mutate_locked(project_root, |knowledge| {
252        let Some(f) = knowledge
253            .facts
254            .iter_mut()
255            .find(|f| f.is_current() && f.category == cat && f.key == k)
256        else {
257            return Err(format!("No current fact found: [{cat}] {k}"));
258        };
259
260        if is_up {
261            f.feedback_up = f.feedback_up.saturating_add(1);
262        } else {
263            f.feedback_down = f.feedback_down.saturating_add(1);
264        }
265        f.last_feedback = Some(Utc::now());
266        Ok((
267            f.quality_score(),
268            f.feedback_up,
269            f.feedback_down,
270            f.confidence,
271        ))
272    });
273
274    let (quality, up, down, conf) = match outcome {
275        Ok((_, Ok(vals))) => vals,
276        Ok((_, Err(msg))) => return msg,
277        Err(e) => return format!("Error: feedback was not saved: {e}"),
278    };
279
280    lean_ctx::core::events::emit(lean_ctx::core::events::EventKind::KnowledgeUpdate {
281        category: cat.to_string(),
282        key: k.to_string(),
283        action: if is_up {
284            "feedback_up"
285        } else {
286            "feedback_down"
287        }
288        .to_string(),
289    });
290
291    format!(
292        "Feedback recorded ({dir}) for [{cat}] {k} (up={up}, down={down}, quality={quality:.2}, confidence={conf:.2}, session={session_id})"
293    )
294}
295
296pub(crate) fn handle_judge(
297    project_root: &str,
298    category: Option<&str>,
299    key: Option<&str>,
300    value: Option<&str>,
301    query: Option<&str>,
302) -> String {
303    let source = match (category, key) {
304        (Some(cat), Some(k)) => format!("{cat}/{k}"),
305        _ => {
306            if let Some(k) = key.or(category) {
307                if k.contains('/') {
308                    k.to_string()
309                } else {
310                    return "Error: judge requires key as 'category/key' (source fact)".to_string();
311                }
312            } else {
313                return "Error: judge requires category+key (source fact) and value (target 'category/key')"
314                    .to_string();
315            }
316        }
317    };
318
319    let Some(target) = value else {
320        return "Error: judge requires value as target 'category/key'".to_string();
321    };
322    let target = target.trim().to_string();
323    if !target.contains('/') {
324        return "Error: target must be 'category/key' format".to_string();
325    }
326
327    let verdict = query.unwrap_or("compatible").trim().to_lowercase();
328    if !matches!(
329        verdict.as_str(),
330        "supersedes" | "compatible" | "unrelated" | "contradicts"
331    ) {
332        return format!(
333            "Error: verdict must be supersedes|compatible|unrelated|contradicts, got '{verdict}'"
334        );
335    }
336
337    // Read-modify-write under the cross-process lock (#326/#594).
338    let result = ProjectKnowledge::mutate_locked(project_root, |knowledge| {
339        let src = current_fact_index(knowledge, &source)?;
340        let tgt = current_fact_index(knowledge, &target)?;
341        if src == tgt {
342            return Err("Error: a fact is not judged against itself".to_string());
343        }
344        let now = Utc::now();
345
346        if verdict == "supersedes" {
347            let tf = &mut knowledge.facts[tgt];
348            tf.valid_until = Some(now);
349            tf.valid_from = tf.valid_from.or(Some(tf.created_at));
350            // The survivor of a judged contradiction is no longer contradicted.
351            let sf = &mut knowledge.facts[src];
352            if sf.review.as_ref().is_some_and(|r| {
353                r.reason == lean_ctx::core::knowledge::FactReviewReason::Contradiction
354            }) {
355                sf.clear_review();
356            }
357        }
358        // J2: both stay current and both are marked until one supersedes the
359        // other. A Hive fact's state reaches Hive with the next sync
360        // (C0 1.9 section 3), which opens a task when nobody resolves it.
361        if verdict == "contradicts" {
362            use lean_ctx::core::knowledge::{FactReviewReason, FactReviewState};
363            for i in [src, tgt] {
364                knowledge.facts[i].mark_review(
365                    FactReviewState::Contradicted,
366                    FactReviewReason::Contradiction,
367                    now,
368                );
369            }
370        }
371
372        knowledge
373            .judged_pairs
374            .push(lean_ctx::core::knowledge::JudgedPair {
375                key_a: source.clone(),
376                key_b: target.clone(),
377                verdict: verdict.clone(),
378                judged_at: now,
379            });
380        Ok(())
381    });
382
383    match result {
384        Ok((_, Ok(()))) => {}
385        Ok((_, Err(msg))) => return msg,
386        Err(e) => return format!("Error: judge save failed: {e}"),
387    }
388
389    let action_desc = match verdict.as_str() {
390        "supersedes" => format!("{source} supersedes {target} (target archived)"),
391        "compatible" => format!("{source} ↔ {target} (compatible, suppressed from future similar)"),
392        "unrelated" => format!("{source} ≠ {target} (unrelated, suppressed from future similar)"),
393        "contradicts" => format!(
394            "{source} ⟂ {target} (contradicts: both marked contradicted until one supersedes the other)"
395        ),
396        _ => unreachable!(),
397    };
398
399    format!("Judged: {action_desc}")
400}
401
402/// The current fact named `spec` (`category/key`; categories and keys may
403/// contain `/`, so the whole name is matched).
404fn current_fact_index(
405    knowledge: &lean_ctx::core::knowledge::ProjectKnowledge,
406    spec: &str,
407) -> Result<usize, String> {
408    let mut found = knowledge.facts.iter().enumerate().filter(|(_, f)| {
409        f.is_current()
410            && spec.len() == f.category.len() + 1 + f.key.len()
411            && spec.starts_with(f.category.as_str())
412            && spec[f.category.len()..].starts_with('/')
413            && spec.ends_with(f.key.as_str())
414    });
415    match (found.next(), found.next()) {
416        (Some((i, _)), None) => Ok(i),
417        (Some(_), Some(_)) => Err(format!("Error: '{spec}' names more than one current fact")),
418        (None, _) => Err(format!("Error: no current fact found for '{spec}'")),
419    }
420}
421
422fn handle_pattern(
423    project_root: &str,
424    pattern_type: Option<&str>,
425    value: Option<&str>,
426    examples: Option<Vec<String>>,
427    session_id: &str,
428) -> String {
429    let Some(pt) = pattern_type else {
430        return "Error: pattern_type is required".to_string();
431    };
432    let Some(desc) = value else {
433        return "Error: value (description) is required for pattern".to_string();
434    };
435    let exs = examples.unwrap_or_default();
436    let policy = match lean_ctx::core::config::Config::load().memory_policy_effective() {
437        Ok(p) => p,
438        Err(e) => {
439            let path = lean_ctx::core::config::Config::path().map_or_else(
440                || "~/.lean-ctx/config.toml".to_string(),
441                |p| p.display().to_string(),
442            );
443            return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
444        }
445    };
446    // Read-modify-write under the cross-process lock (#326/#594).
447    match ProjectKnowledge::mutate_locked(project_root, |knowledge| {
448        knowledge.add_pattern(pt, desc, exs, session_id, &policy);
449    }) {
450        Ok(_) => format!("Pattern [{pt}] added: {desc}"),
451        Err(e) => format!("Pattern add failed: {e}"),
452    }
453}
454
455fn handle_status(project_root: &str) -> String {
456    let Some(knowledge) = ProjectKnowledge::load(project_root) else {
457        return "No knowledge stored for this project yet. Use ctx_knowledge(action=\"remember\") to start.".to_string();
458    };
459
460    let current_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
461    let archived_facts = knowledge.facts.len() - current_facts;
462
463    let mut out = format!(
464        "Project Knowledge: {} active facts ({} archived), {} patterns, {} history entries\n",
465        current_facts,
466        archived_facts,
467        knowledge.patterns.len(),
468        knowledge.history.len()
469    );
470    out.push_str(&format!(
471        "Last updated: {}\n",
472        knowledge.updated_at.format("%Y-%m-%d %H:%M UTC")
473    ));
474
475    let rooms = knowledge.list_rooms();
476    if !rooms.is_empty() {
477        out.push_str("Rooms: ");
478        let room_strs: Vec<String> = rooms.iter().map(|(c, n)| format!("{c}({n})")).collect();
479        out.push_str(&room_strs.join(", "));
480        out.push('\n');
481    }
482
483    out.push_str(&knowledge.format_summary());
484    out
485}
486
487/// Per-layer lifecycle report (GL#445): item counts, effective policies, and
488/// the next enforcement action for every memory layer. Read-only.
489fn handle_lifecycle_report(project_root: &str) -> String {
490    let policy = match load_policy_or_error() {
491        Ok(p) => p,
492        Err(e) => return e,
493    };
494
495    let mut out = String::from("=== Memory Lifecycle Report ===\n");
496
497    // Knowledge layer (long-term facts, archive-only eviction).
498    match ProjectKnowledge::load(project_root) {
499        Some(k) => {
500            let active = k.facts.iter().filter(|f| f.is_current()).count();
501            let archived = k.facts.len() - active;
502            out.push_str(&format!(
503                "knowledge   {active} active / {archived} archived (no fact limit)\n\
504                 \x20           decay {}/day, stale >{}d, consolidate-sim {:.2}\n\
505                 \x20           GC: by quality only (decay, near-duplicate merge, low-confidence/stale); eviction archives, never deletes\n",
506                policy.lifecycle.decay_rate,
507                policy.lifecycle.stale_days,
508                policy.lifecycle.similarity_threshold,
509            ));
510        }
511        None => out.push_str("knowledge   (no store yet)\n"),
512    }
513
514    // Archive files (restorable via recall rehydration).
515    let archives = lean_ctx::core::memory_lifecycle::list_archives(project_root);
516    out.push_str(&format!(
517        "archives    {} file(s); auto-rehydrated when recall misses\n",
518        archives.len()
519    ));
520
521    // Embeddings layer (semantic index over knowledge facts).
522    #[cfg(feature = "embeddings")]
523    {
524        let hash = lean_ctx::core::project_hash::hash_project_root(project_root);
525        let n = lean_ctx::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(&hash)
526            .map_or(0, |idx| idx.entries.len());
527        out.push_str(&format!(
528            "embeddings  {n} vector(s); compacted against knowledge on remember\n"
529        ));
530    }
531    #[cfg(not(feature = "embeddings"))]
532    out.push_str("embeddings  (feature disabled in this build)\n");
533
534    out.push_str(
535        "\nLayer boundaries: session = working memory (now) | knowledge = long-term (ETL via consolidate) | providers = external (read-through)\n",
536    );
537    out
538}
539
540fn handle_health(project_root: &str) -> String {
541    let Some(knowledge) = ProjectKnowledge::load(project_root) else {
542        return "No knowledge stored. Nothing to report.".to_string();
543    };
544
545    let total = knowledge.facts.len();
546    let current: Vec<_> = knowledge.facts.iter().filter(|f| f.is_current()).collect();
547    let archived = total - current.len();
548
549    let mut low_quality = 0u32;
550    let mut high_quality = 0u32;
551    let mut stale_candidates = 0u32;
552    let mut total_quality: f32 = 0.0;
553    let mut never_retrieved = 0u32;
554    let mut room_counts: std::collections::HashMap<String, (u32, f32)> =
555        std::collections::HashMap::new();
556
557    let now = chrono::Utc::now();
558    for f in &current {
559        let q = f.quality_score();
560        total_quality += q;
561        if q < 0.4 {
562            low_quality += 1;
563        } else if q >= 0.8 {
564            high_quality += 1;
565        }
566        if f.retrieval_count == 0 {
567            never_retrieved += 1;
568        }
569        let age_days = (now - f.created_at).num_days();
570        if age_days > 30 && f.retrieval_count == 0 {
571            stale_candidates += 1;
572        }
573
574        let entry = room_counts.entry(f.category.clone()).or_insert((0, 0.0));
575        entry.0 += 1;
576        entry.1 += q;
577    }
578
579    let avg_quality = if current.is_empty() {
580        0.0
581    } else {
582        total_quality / current.len() as f32
583    };
584
585    let mut out = String::from("=== Knowledge Health Report ===\n");
586    out.push_str(&format!(
587        "Total: {} facts ({} active, {} archived)\n",
588        total,
589        current.len(),
590        archived
591    ));
592    out.push_str(&format!("Avg Quality: {avg_quality:.2}\n"));
593    out.push_str(&format!(
594        "Distribution: {high_quality} high (>=0.8) | {low_quality} low (<0.4)\n"
595    ));
596    out.push_str(&format!(
597        "Stale (>30d, never retrieved): {stale_candidates}\n"
598    ));
599    out.push_str(&format!("Never retrieved: {never_retrieved}\n"));
600
601    if !room_counts.is_empty() {
602        out.push_str("\nRoom Balance:\n");
603        let mut rooms: Vec<_> = room_counts.into_iter().collect();
604        rooms.sort_by_key(|x| std::cmp::Reverse(x.1.0));
605        for (cat, (count, total_q)) in &rooms {
606            let avg = if *count > 0 {
607                total_q / *count as f32
608            } else {
609                0.0
610            };
611            out.push_str(&format!("  {cat}: {count} facts, avg quality {avg:.2}\n"));
612        }
613    }
614
615    let policy = lean_ctx::core::config::Config::load()
616        .memory_policy_effective()
617        .unwrap_or_default();
618    out.push_str(&format!(
619        "\nPolicy: no fact limit, max {} patterns\n",
620        policy.knowledge.max_patterns
621    ));
622
623    out
624}
625
626fn handle_remove(project_root: &str, category: Option<&str>, key: Option<&str>) -> String {
627    let Some(cat) = category else {
628        return "Error: category is required for remove".to_string();
629    };
630    let Some(k) = key else {
631        return "Error: key is required for remove".to_string();
632    };
633    let policy = match lean_ctx::core::config::Config::load().memory_policy_effective() {
634        Ok(p) => p,
635        Err(e) => {
636            let path = lean_ctx::core::config::Config::path().map_or_else(
637                || "~/.lean-ctx/config.toml".to_string(),
638                |p| p.display().to_string(),
639            );
640            return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
641        }
642    };
643    // Read-modify-write under the cross-process lock (#326/#594). The embedding
644    // index is a separate store, so it is synced afterwards from the committed
645    // knowledge — mirroring handle_remember.
646    let (knowledge, removed) = match ProjectKnowledge::mutate_locked(project_root, |knowledge| {
647        knowledge.remove_fact(cat, k)
648    }) {
649        Ok(pair) => pair,
650        Err(e) => return format!("Error: removal was not saved: {e}"),
651    };
652
653    if !removed {
654        return format!("No fact found: [{cat}] {k}");
655    }
656
657    #[cfg(feature = "embeddings")]
658    {
659        // Serialize the embedding side-car under the same per-project lock as
660        // the fact removal and compact against fresh on-disk knowledge, so a
661        // concurrent `remember` cannot clobber it (issue #412).
662        ProjectKnowledge::with_project_lock(project_root, || {
663            if let Some(mut idx) =
664                lean_ctx::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(
665                    &knowledge.project_hash,
666                )
667            {
668                idx.remove(cat, k);
669                let fresh = ProjectKnowledge::load(project_root);
670                let kref = fresh.as_ref().unwrap_or(&knowledge);
671                lean_ctx::core::knowledge_embedding::compact_against_knowledge(
672                    &mut idx, kref, &policy,
673                );
674                let _ = idx.save();
675            }
676        });
677    }
678    #[cfg(not(feature = "embeddings"))]
679    let _ = (&knowledge, &policy);
680
681    format!("Removed [{cat}] {k}")
682}
683
684fn handle_export(project_root: &str) -> String {
685    let result = lean_ctx::core::policy::runtime::with_explicit_project_source_view(
686        std::path::Path::new(project_root),
687        |authority| -> Result<String, String> {
688            let admission =
689                lean_ctx::core::knowledge::protection::KnowledgeAdmission::capture(project_root)?;
690            let Some(knowledge) = ProjectKnowledge::load_for_admission(project_root, &admission)?
691            else {
692                admission.authorize_publication(authority)?;
693                return Ok("No knowledge to export.".to_string());
694            };
695            if knowledge.facts.is_empty()
696                && knowledge.patterns.is_empty()
697                && knowledge.history.is_empty()
698            {
699                admission.authorize_publication(authority)?;
700                return Ok("No knowledge to export.".to_string());
701            }
702            let data_dir = lean_ctx::core::data_dir::lean_ctx_data_dir()?;
703            let export_dir = data_dir.join("exports").join("knowledge");
704            let ts = Utc::now().format("%Y%m%d-%H%M%S");
705            let filename = format!(
706                "knowledge-{}-{ts}.json",
707                short_hash(&knowledge.project_hash)
708            );
709            let path = export_dir.join(filename);
710            let mut json = serde_json::to_string_pretty(&knowledge)
711                .map_err(|_| "Export serialization failed")?;
712            json.push('\n');
713            admission.authorize_publication(authority)?;
714            lean_ctx::config_io::write_atomic_with_backup(&path, &json)?;
715            Ok(format!(
716                "Export saved: {} (active facts: {}, patterns: {}, history: {})",
717                path.display(),
718                knowledge.facts.iter().filter(|f| f.is_current()).count(),
719                knowledge.patterns.len(),
720                knowledge.history.len()
721            ))
722        },
723    );
724    match result {
725        Ok(Ok(message)) => message,
726        Ok(Err(error)) | Err(error) => format!("Export failed: {error}"),
727    }
728}
729
730fn admitted_snapshot(
731    project_root: &str,
732    admission: &lean_ctx::core::knowledge::protection::KnowledgeAdmission,
733) -> Result<Option<lean_ctx::core::knowledge::KnowledgeSnapshot>, String> {
734    let Some(knowledge) = ProjectKnowledge::load_for_admission(project_root, admission)? else {
735        return Ok(None);
736    };
737    let visible_nodes: std::collections::HashSet<(String, String)> = knowledge
738        .facts
739        .iter()
740        .map(|fact| (fact.category.clone(), fact.key.clone()))
741        .collect();
742    let relations =
743        lean_ctx::core::knowledge_relations::KnowledgeRelationGraph::load(&knowledge.project_hash)
744            .map(|graph| {
745                graph
746                    .edges
747                    .into_iter()
748                    .filter(|edge| {
749                        visible_nodes.contains(&(edge.from.category.clone(), edge.from.key.clone()))
750                            && visible_nodes
751                                .contains(&(edge.to.category.clone(), edge.to.key.clone()))
752                            && serde_json::to_value(edge)
753                                .is_ok_and(|value| admission.admits_archive_value(&value))
754                    })
755                    .collect()
756            })
757            .unwrap_or_default();
758    admission.authorize_gateway()?;
759    Ok(Some(
760        lean_ctx::core::knowledge::KnowledgeSnapshot::from_project(&knowledge, relations),
761    ))
762}
763
764/// Exports the project's current knowledge as an Open Knowledge Format (OKF)
765/// bundle — a directory of Markdown files, portable and vendor-neutral. Renders
766/// from the shared [`lean_ctx::core::knowledge::KnowledgeSnapshot`], so it never
767/// disagrees with a ctxpkg export on what the knowledge is. `out` is the target
768/// directory (defaults to the data dir's `exports/okf/<hash>`).
769pub fn handle_export_okf(project_root: &str, out: Option<&str>) -> String {
770    let result = lean_ctx::core::policy::runtime::with_explicit_project_source_view(
771        std::path::Path::new(project_root),
772        |authority| -> Result<String, String> {
773            let admission =
774                lean_ctx::core::knowledge::protection::KnowledgeAdmission::capture(project_root)?;
775            let Some(snapshot) = admitted_snapshot(project_root, &admission)? else {
776                admission.authorize_publication(authority)?;
777                return Ok("No knowledge to export.".to_string());
778            };
779            if snapshot.is_empty() {
780                admission.authorize_publication(authority)?;
781                return Ok("No knowledge to export.".to_string());
782            }
783            let dir = match out {
784                Some(p) => std::path::PathBuf::from(p),
785                None => lean_ctx::core::data_dir::lean_ctx_data_dir()?
786                    .join("exports")
787                    .join("okf")
788                    .join(short_hash(&snapshot.project_hash)),
789            };
790            let bundle = lean_ctx::core::knowledge::okf::to_okf_bundle(&snapshot);
791            admission.authorize_publication(authority)?;
792            lean_ctx::core::knowledge::okf::write_okf_bundle(&dir, &bundle)?;
793            Ok(format!(
794                "OKF bundle exported: {} ({} concepts, {} patterns, {} relations)",
795                dir.display(),
796                snapshot.current_facts().len(),
797                snapshot.patterns.len(),
798                snapshot.relations.len()
799            ))
800        },
801    );
802    match result {
803        Ok(Ok(message)) => message,
804        Ok(Err(error)) | Err(error) => format!("Export failed: {error}"),
805    }
806}
807
808/// Imports knowledge from `path`. A directory is treated as an OKF bundle; a
809/// file falls back to the native JSON / simple-array / JSONL import.
810pub fn handle_import(
811    project_root: &str,
812    path: &str,
813    merge: lean_ctx::core::knowledge::ImportMerge,
814    session_id: &str,
815) -> String {
816    if std::path::Path::new(path).is_dir() {
817        return handle_import_okf(project_root, std::path::Path::new(path), merge, session_id);
818    }
819
820    let data = match std::fs::read_to_string(path) {
821        Ok(d) => d,
822        Err(e) => return format!("Failed to read {path}: {e}"),
823    };
824    let facts = match lean_ctx::core::knowledge::parse_import_data(&data) {
825        Ok(f) => f,
826        Err(e) => return format!("Parse error: {e}"),
827    };
828    match ProjectKnowledge::mutate_locked(project_root, |k| {
829        k.import_facts(facts, merge, session_id)
830    }) {
831        Ok((_, r)) => format!(
832            "Import complete: {} added, {} skipped, {} replaced",
833            r.added, r.skipped, r.replaced
834        ),
835        Err(e) => format!("Import failed: {e}"),
836    }
837}
838
839/// OKF directory import: facts via `import_facts`, patterns via `add_pattern`,
840/// then relations via the relation graph — guarded so an edge is only created
841/// when both endpoints are current facts (facts first, then edges).
842fn handle_import_okf(
843    project_root: &str,
844    dir: &std::path::Path,
845    merge: lean_ctx::core::knowledge::ImportMerge,
846    session_id: &str,
847) -> String {
848    let imp = match lean_ctx::core::knowledge::okf::from_okf_dir(dir) {
849        Ok(i) => i,
850        Err(e) => return format!("OKF import failed: {e}"),
851    };
852    let policy = match load_policy_or_error() {
853        Ok(p) => p,
854        Err(e) => return e,
855    };
856
857    let pattern_count = imp.patterns.len();
858    let (knowledge, result) = match ProjectKnowledge::mutate_locked(project_root, |k| {
859        let r = k.import_facts(imp.facts, merge, session_id);
860        for p in &imp.patterns {
861            k.add_pattern(
862                &p.pattern_type,
863                &p.description,
864                p.examples.clone(),
865                session_id,
866                &policy,
867            );
868        }
869        r
870    }) {
871        Ok(v) => v,
872        Err(e) => return format!("OKF import failed: {e}"),
873    };
874
875    // Guarded edges: only between facts that are current after the import.
876    let current: std::collections::HashSet<(String, String)> = knowledge
877        .facts
878        .iter()
879        .filter(|f| f.is_current())
880        .map(|f| (f.category.clone(), f.key.clone()))
881        .collect();
882    let mut relations_added = 0usize;
883    if !imp.edges.is_empty() {
884        let saved = lean_ctx::core::knowledge_relations::KnowledgeRelationGraph::mutate_locked(
885            project_root,
886            &knowledge.project_hash,
887            |graph| {
888                let mut added = 0usize;
889                for e in imp.edges {
890                    let endpoints_current = current
891                        .contains(&(e.from.category.clone(), e.from.key.clone()))
892                        && current.contains(&(e.to.category.clone(), e.to.key.clone()));
893                    if endpoints_current && graph.upsert_edge(e.from, e.to, e.kind, session_id) {
894                        added += 1;
895                    }
896                }
897                Some(added)
898            },
899        );
900        match saved {
901            Ok(added) => relations_added = added.unwrap_or_default(),
902            Err(err) => tracing::warn!("OKF import: relations save failed: {err}"),
903        }
904    }
905
906    let mut out = format!(
907        "OKF import: {} added, {} skipped, {} replaced, {pattern_count} patterns, {relations_added} relations",
908        result.added, result.skipped, result.replaced
909    );
910    let warnings = lean_ctx::core::knowledge::okf::lint_okf_bundle(dir);
911    if !warnings.is_empty() {
912        out.push_str(&format!("\nLint warnings ({}):", warnings.len()));
913        for w in warnings.iter().take(10) {
914            out.push_str(&format!("\n  - {w}"));
915        }
916    }
917    out
918}
919
920fn handle_consolidate(project_root: &str) -> String {
921    match consolidate_project_knowledge(project_root) {
922        Ok(report) => format_consolidation_report(&report),
923        Err(e) => e,
924    }
925}
926
927/// Dry-run consolidate: preview imports + reclaim with zero writes (#995).
928fn handle_consolidate_preview(project_root: &str) -> String {
929    match consolidate_project_knowledge_with(
930        project_root,
931        &ConsolidateOptions::manual().into_dry_run(),
932    ) {
933        Ok(report) => format_consolidation_report(&report),
934        Err(e) => e,
935    }
936}
937
938/// Explicit cross-store restore from archive (#995 Phase 6). `store` selects a
939/// single store (all when `None`); `query` filters by substring; `limit` caps
940/// the total restored (default [`DEFAULT_RESTORE_LIMIT`]).
941fn handle_restore(
942    project_root: &str,
943    store: Option<&str>,
944    query: Option<&str>,
945    limit: Option<usize>,
946) -> String {
947    let store = match store {
948        Some(s) if lean_ctx::core::memory_archive::MemoryStore::is_retired(s) => {
949            return lean_ctx::core::memory_archive::RETIRED_PROCEDURES_STORE_NOTICE.to_string();
950        }
951        Some(s) => match lean_ctx::core::memory_archive::MemoryStore::parse(s) {
952            Some(ms) => Some(ms),
953            None => {
954                return format!("Unknown store: {s}. Use: facts, history, patterns");
955            }
956        },
957        None => None,
958    };
959    let opts = RestoreOptions::new(
960        store,
961        query.map(str::to_string),
962        limit.unwrap_or(DEFAULT_RESTORE_LIMIT),
963    );
964    match run_restore(project_root, &opts) {
965        Ok(report) => format_restore_report(&report),
966        Err(e) => e,
967    }
968}
969
970fn handle_timeline(project_root: &str, category: Option<&str>) -> Result<String, String> {
971    let Some(knowledge) = ProjectKnowledge::load(project_root) else {
972        return Ok("No knowledge stored yet.".to_string());
973    };
974
975    let policy = load_policy_or_error()?;
976
977    let Some(cat) = category else {
978        return Err("Error: category is required for timeline".to_string());
979    };
980
981    let facts = knowledge.timeline(cat);
982    if facts.is_empty() {
983        return Ok(format!("No history for category '{cat}'."));
984    }
985
986    let mut ordered: Vec<&lean_ctx::core::knowledge::KnowledgeFact> = facts;
987    ordered.sort_by(|a, b| {
988        let a_start = a.valid_from.unwrap_or(a.created_at);
989        let b_start = b.valid_from.unwrap_or(b.created_at);
990        a_start
991            .cmp(&b_start)
992            .then_with(|| a.last_confirmed.cmp(&b.last_confirmed))
993            .then_with(|| a.key.cmp(&b.key))
994            .then_with(|| a.value.cmp(&b.value))
995    });
996
997    let total = ordered.len();
998    let limit = policy.knowledge.timeline_limit;
999    if ordered.len() > limit {
1000        ordered = ordered[ordered.len() - limit..].to_vec();
1001    }
1002    // Only what may be delivered now (see `fact_served`).
1003    ordered.retain(|f| fact_served(project_root, &f.category, &f.key, &f.value));
1004
1005    let mut out = format!(
1006        "Timeline [{cat}] (showing {}/{} entries):\n",
1007        ordered.len(),
1008        total
1009    );
1010    for f in &ordered {
1011        let status = if f.is_current() {
1012            "CURRENT"
1013        } else {
1014            "archived"
1015        };
1016        let valid_range = match (f.valid_from, f.valid_until) {
1017            (Some(from), Some(until)) => format!(
1018                "{} → {}",
1019                from.format("%Y-%m-%d %H:%M"),
1020                until.format("%Y-%m-%d %H:%M")
1021            ),
1022            (Some(from), None) => format!("{} → now", from.format("%Y-%m-%d %H:%M")),
1023            _ => "unknown".to_string(),
1024        };
1025        out.push_str(&format!(
1026            "  {} = {} [{status}] ({valid_range}) conf={:.0}% x{}\n",
1027            f.key,
1028            f.as_delivered(f.value.clone()),
1029            f.confidence * 100.0,
1030            f.confirmation_count
1031        ));
1032    }
1033    Ok(out)
1034}
1035
1036fn handle_rooms(project_root: &str) -> String {
1037    let Some(knowledge) = ProjectKnowledge::load(project_root) else {
1038        return "No knowledge stored yet.".to_string();
1039    };
1040
1041    let policy = match load_policy_or_error() {
1042        Ok(p) => p,
1043        Err(e) => return e,
1044    };
1045
1046    let rooms = knowledge.list_rooms();
1047    if rooms.is_empty() {
1048        return "No knowledge rooms yet. Use ctx_knowledge(action=\"remember\", category=\"...\") to create rooms.".to_string();
1049    }
1050
1051    let mut rooms = rooms;
1052    rooms.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
1053    let total = rooms.len();
1054    rooms.truncate(policy.knowledge.rooms_limit);
1055
1056    let mut out = format!(
1057        "Knowledge Rooms (showing {}/{} rooms, project: {}):\n",
1058        rooms.len(),
1059        total,
1060        short_hash(&knowledge.project_hash)
1061    );
1062    for (cat, count) in &rooms {
1063        out.push_str(&format!("  [{cat}] {count} fact(s)\n"));
1064    }
1065    out
1066}
1067
1068fn handle_bridge_publish(project_root: &str, session_id: &str) -> String {
1069    let knowledge = ProjectKnowledge::load_or_create(project_root);
1070    let published = lean_ctx::core::knowledge_bridge::KnowledgeBridge::mutate_locked(
1071        &knowledge.project_hash,
1072        |bridge| {
1073            (
1074                bridge.publish(session_id, &knowledge.facts),
1075                bridge.shared_facts.len(),
1076            )
1077        },
1078    );
1079    match published {
1080        Ok((count, total)) => {
1081            format!("Published {count} fact(s) to bridge (total: {total}, agent: {session_id})")
1082        }
1083        Err(e) => format!("Error: context publication was not saved: {e}"),
1084    }
1085}
1086
1087fn handle_bridge_pull(project_root: &str, session_id: &str) -> String {
1088    let project_hash = lean_ctx::core::project_hash::hash_project_root(project_root);
1089    let bridge = lean_ctx::core::knowledge_bridge::KnowledgeBridge::load_or_create(&project_hash);
1090    let entries = bridge.pull(session_id);
1091    if entries.is_empty() {
1092        return "No facts available from other agents.".to_string();
1093    }
1094
1095    let policy = match load_policy_or_error() {
1096        Ok(p) => p,
1097        Err(e) => return e,
1098    };
1099
1100    let missing = |target: &ProjectKnowledge,
1101                   entry: &lean_ctx::core::knowledge_bridge::BridgeEntry| {
1102        let fact = lean_ctx::core::knowledge_bridge::KnowledgeBridge::entry_to_fact(entry);
1103        let present = target
1104            .facts
1105            .iter()
1106            .any(|f| f.is_current() && f.category == fact.category && f.key == fact.key);
1107        (!present).then_some(fact)
1108    };
1109    // Nothing to import: the store is neither locked nor rewritten.
1110    let current = ProjectKnowledge::load_or_create(project_root);
1111    if entries
1112        .iter()
1113        .all(|entry| missing(&current, entry).is_none())
1114    {
1115        return format!(
1116            "Bridge has {} fact(s) from other agents, but all already exist locally.",
1117            entries.len()
1118        );
1119    }
1120
1121    // The existence check and the import run again on the store as it is
1122    // inside the project lock, so a parallel writer's facts are never
1123    // overwritten.
1124    let imported = ProjectKnowledge::mutate_locked(project_root, |target| {
1125        let mut imported = 0u32;
1126        for entry in &entries {
1127            if let Some(fact) = missing(target, entry) {
1128                target.remember(
1129                    &fact.category,
1130                    &fact.key,
1131                    &fact.value,
1132                    session_id,
1133                    fact.confidence,
1134                    &policy,
1135                );
1136                imported += 1;
1137            }
1138        }
1139        imported
1140    });
1141
1142    match imported {
1143        Ok((_, 0)) => format!(
1144            "Bridge has {} fact(s) from other agents, but all already exist locally.",
1145            entries.len()
1146        ),
1147        Ok((_, imported)) => format!(
1148            "Pulled {imported}/{} fact(s) from bridge into local knowledge.",
1149            entries.len()
1150        ),
1151        Err(e) => format!("Error: context import was not saved: {e}"),
1152    }
1153}
1154
1155fn handle_bridge_status(project_root: &str) -> String {
1156    let knowledge = ProjectKnowledge::load_or_create(project_root);
1157    let bridge =
1158        lean_ctx::core::knowledge_bridge::KnowledgeBridge::load_or_create(&knowledge.project_hash);
1159    bridge.summary()
1160}
1161
1162fn handle_wakeup(project_root: &str) -> String {
1163    let Some(knowledge) = ProjectKnowledge::load(project_root) else {
1164        return "No knowledge for wake-up briefing.".to_string();
1165    };
1166    let aaak = knowledge.format_aaak();
1167    if aaak.is_empty() {
1168        return "No knowledge yet. Start using ctx_knowledge(action=\"remember\") to build project memory.".to_string();
1169    }
1170    format!("{WAKEUP_HEAD}{aaak}")
1171}
1172
1173const WAKEUP_HEAD: &str = "WAKE-UP BRIEFING:\n";
1174
1175/// The delta form of a wake-up briefing, in the one channel that has a
1176/// history: a tool result stays in the caller's context.
1177///
1178/// The Engine cannot see a context. What it can check is the existing
1179/// possession proof of a request — the presenter capability its host showed
1180/// (`core::archive::presented`) — and what left the release point of the
1181/// tool pipeline. The short answer is given only when all three hold:
1182///
1183/// 1. the request shows an issued presenter capability that may still
1184///    present (`shown_presenter_reference`); the capability of the process,
1185///    which every connection without one shares, never counts;
1186/// 2. a briefing with exactly this admitted text was RELEASED to a request of
1187///    that same presenter ([`note_wakeup_release`], called where the result
1188///    blocks are the bytes the client receives — not when the text is built);
1189/// 3. the caller names the hash of that text in `value`.
1190///
1191/// Anything else is answered in full: no capability, another presenter,
1192/// nothing on record (another process, a result that was withheld, replaced,
1193/// shortened or compressed after it was built), a record that cannot be read in time, a
1194/// changed memory or policy (another admitted text), no or another hash.
1195/// `admitted` is the briefing after `protect_active` and `admit_recovered`.
1196fn wakeup_delta(held: Option<&str>, admitted: String) -> String {
1197    let holder = lean_ctx::core::archive::presented::shown_presenter_reference();
1198    wakeup_delta_for(holder.as_deref(), held, admitted)
1199}
1200
1201const WAKEUP_HASH_LINE: &str = "\n[memory hash ";
1202
1203fn wakeup_hash(briefing: &str) -> String {
1204    blake3::hash(briefing.as_bytes()).to_hex()[..16].to_string()
1205}
1206
1207fn wakeup_delta_for(holder: Option<&str>, held: Option<&str>, admitted: String) -> String {
1208    let Some(holder) = holder else {
1209        // No possession proof exists for this request: the briefing as before.
1210        return admitted;
1211    };
1212    if !admitted.starts_with(WAKEUP_HEAD)
1213        || admitted.contains(lean_ctx::core::sanitize::FENCE_REFUSAL)
1214    {
1215        return admitted;
1216    }
1217    let hash = wakeup_hash(&admitted);
1218    if held.map(str::trim) == Some(hash.as_str()) && wakeup_released(holder, &hash) {
1219        return format!(
1220            "{WAKEUP_HEAD}[project memory unchanged since the briefing with hash {hash}]"
1221        );
1222    }
1223    wakeup_full(&admitted, &hash)
1224}
1225
1226/// The full answer: the admitted briefing and, as its last line, its hash.
1227fn wakeup_full(admitted: &str, hash: &str) -> String {
1228    format!(
1229        "{admitted}{WAKEUP_HASH_LINE}{hash} — while this briefing is still in your context, \
1230         ctx_knowledge(action=\"wakeup\", value=\"{hash}\") answers \"unchanged\" instead of repeating it]"
1231    )
1232}
1233
1234type WakeupReleases = std::sync::Arc<tokio::sync::RwLock<Vec<(String, String)>>>;
1235
1236/// Briefings released by this process: (presenter reference, hash). Bounded,
1237/// in memory only: a new process has released nothing.
1238fn wakeup_releases() -> &'static WakeupReleases {
1239    static RELEASED: std::sync::OnceLock<WakeupReleases> = std::sync::OnceLock::new();
1240    RELEASED.get_or_init(WakeupReleases::default)
1241}
1242
1243/// Whether a briefing with this hash was released to this presenter. A record
1244/// that cannot be read within the lock bound counts as none.
1245fn wakeup_released(holder: &str, hash: &str) -> bool {
1246    crate::server::bounded_lock::read(wakeup_releases(), "ctx_knowledge wakeup release read")
1247        .is_some_and(|released| {
1248            released
1249                .iter()
1250                .any(|(presenter, released_hash)| presenter == holder && released_hash == hash)
1251        })
1252}
1253
1254/// The release point of the tool pipeline reports the text blocks a client
1255/// receives for a call that did not fail. A block counts as a released
1256/// briefing only when it holds, byte for byte and in one piece, the full
1257/// answer this module builds for the briefing it contains — from the head to
1258/// the end of the closing hash line. Text the pipeline put before or after it
1259/// (a notice, a hint, a checkpoint) takes nothing from the briefing and does
1260/// not matter; a block in which the answer was shortened anywhere (the
1261/// closing line included), compressed, redacted or replaced is no such block.
1262/// A request without an issued presenter capability records nothing.
1263pub(crate) fn note_wakeup_release(delivered_blocks: &[&str]) {
1264    let Some(holder) = lean_ctx::core::archive::presented::shown_presenter_reference() else {
1265        return;
1266    };
1267    note_wakeup_release_for(&holder, delivered_blocks);
1268}
1269
1270fn note_wakeup_release_for(holder: &str, delivered_blocks: &[&str]) {
1271    const REMEMBERED: usize = 64;
1272    for block in delivered_blocks {
1273        // Head and marker only locate the answer; what counts is that the
1274        // block holds it whole, from the head to the end of its closing line.
1275        let Some(answer) = block.find(WAKEUP_HEAD).map(|start| &block[start..]) else {
1276            continue;
1277        };
1278        let Some((briefing, _)) = answer.rsplit_once(WAKEUP_HASH_LINE) else {
1279            continue;
1280        };
1281        let hash = wakeup_hash(briefing);
1282        if !answer.starts_with(&wakeup_full(briefing, &hash)) {
1283            continue;
1284        }
1285        let entry = (holder.to_string(), hash);
1286        // Not recorded within the lock bound: the next wake-up is answered in full.
1287        let Some(mut released) = crate::server::bounded_lock::write(
1288            wakeup_releases(),
1289            "ctx_knowledge wakeup release write",
1290        ) else {
1291            return;
1292        };
1293        if !released.contains(&entry) {
1294            if released.len() == REMEMBERED {
1295                released.remove(0);
1296            }
1297            released.push(entry);
1298        }
1299    }
1300}