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