Skip to main content

mempal_runtime/
context.rs

1#![warn(clippy::all)]
2
3use std::collections::BTreeSet;
4use std::path::PathBuf;
5
6use serde::Serialize;
7use thiserror::Error;
8
9use crate::core::{
10    anchor,
11    db::Database,
12    db::DbError,
13    types::{
14        AnchorKind, KnowledgeCard, KnowledgeCardFilter, KnowledgeEvidenceLink,
15        KnowledgeEvidenceRole, KnowledgeStatus, KnowledgeTier, MemoryDomain, MemoryKind,
16        RouteDecision, SearchResult, TriggerHints,
17    },
18};
19use crate::embed::{EmbedError, Embedder};
20use crate::search::{SearchError, SearchFilters, SearchOptions, search_with_vector_options};
21
22pub type Result<T> = std::result::Result<T, ContextError>;
23
24#[derive(Debug, Error)]
25pub enum ContextError {
26    #[error("failed to derive context anchors")]
27    DeriveAnchor(#[from] anchor::AnchorError),
28    #[error("failed to embed context query")]
29    EmbedQuery(#[source] EmbedError),
30    #[error("embedder returned no context query vector")]
31    MissingQueryVector,
32    #[error("failed to search context candidates")]
33    Search(#[source] SearchError),
34    #[error("failed to load context drawer metadata")]
35    LoadDrawer(#[source] DbError),
36    #[error("failed to load context card metadata")]
37    LoadCard(#[source] DbError),
38}
39
40#[derive(Debug, Clone)]
41pub struct ContextRequest {
42    pub query: String,
43    pub domain: MemoryDomain,
44    pub field: String,
45    pub cwd: PathBuf,
46    pub include_evidence: bool,
47    pub include_cards: bool,
48    pub max_items: usize,
49    pub dao_tian_limit: usize,
50    /// P106: include the read-only `distill_suggestions` signal. Defaults to
51    /// true at the CLI/MCP surfaces; never changes the assembled sections.
52    pub include_distill_suggestions: bool,
53}
54
55/// P106 distill-signal thresholds. Fixed constants in v1 (not config-tunable).
56pub const DISTILL_SIGNAL_MIN_EVIDENCE: i64 = 5;
57pub const DISTILL_SIGNAL_MAX_SUGGESTIONS: usize = 3;
58pub const DISTILL_SIGNAL_SAMPLE_LIMIT: usize = 3;
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
61pub struct ContextAnchor {
62    pub anchor_kind: AnchorKind,
63    pub anchor_id: String,
64}
65
66#[derive(Debug, Clone, Serialize)]
67pub struct ContextPack {
68    pub query: String,
69    pub domain: MemoryDomain,
70    pub field: String,
71    pub anchors: Vec<ContextAnchor>,
72    pub sections: Vec<ContextSection>,
73    /// P106: read-only signal flagging fields where evidence is dense but no
74    /// promoted knowledge exists yet. Empty when disabled or nothing qualifies.
75    /// Never alters `sections`.
76    pub distill_suggestions: Vec<DistillSuggestion>,
77}
78
79/// P106: a read-only suggestion that a `field` is worth distilling. The agent
80/// MAY act on it via the explicit distill -> gate lifecycle; mempal never acts.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
82pub struct DistillSuggestion {
83    pub field: String,
84    pub evidence_count: usize,
85    pub sample_evidence_drawer_ids: Vec<String>,
86    pub suggested_tier: String,
87}
88
89#[derive(Debug, Clone, Serialize)]
90pub struct ContextSection {
91    pub name: String,
92    pub items: Vec<ContextItem>,
93}
94
95#[derive(Debug, Clone, Serialize)]
96pub struct ContextItem {
97    pub drawer_id: String,
98    pub source_file: String,
99    pub text: String,
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub card_id: Option<String>,
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub tier: Option<KnowledgeTier>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub status: Option<KnowledgeStatus>,
106    pub anchor_kind: AnchorKind,
107    pub anchor_id: String,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub parent_anchor_id: Option<String>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub trigger_hints: Option<TriggerHints>,
112    #[serde(skip_serializing_if = "Vec::is_empty", default)]
113    pub evidence_citations: Vec<ContextEvidenceCitation>,
114}
115
116#[derive(Debug, Clone, Serialize)]
117pub struct ContextEvidenceCitation {
118    pub evidence_drawer_id: String,
119    pub role: KnowledgeEvidenceRole,
120    pub source_file: String,
121}
122
123#[derive(Debug, Clone)]
124struct AnchorCandidate {
125    anchor_kind: AnchorKind,
126    anchor_id: String,
127    domain: MemoryDomain,
128}
129
130#[derive(Debug, Clone)]
131struct CandidateQuery<'a> {
132    request: &'a ContextRequest,
133    query_vector: &'a [f32],
134    route: &'a RouteDecision,
135    anchor: &'a AnchorCandidate,
136    memory_kind: MemoryKind,
137    tier: Option<KnowledgeTier>,
138    status: Option<KnowledgeStatus>,
139    top_k: usize,
140}
141
142struct CardAppendState<'a> {
143    seen: &'a mut BTreeSet<String>,
144    items: &'a mut Vec<ContextItem>,
145    remaining: &'a mut usize,
146    tier_remaining: &'a mut usize,
147}
148
149pub async fn assemble_context<E: Embedder + ?Sized>(
150    db: &Database,
151    embedder: &E,
152    request: ContextRequest,
153) -> Result<ContextPack> {
154    let query_vector = embedder
155        .embed(&[request.query.as_str()])
156        .await
157        .map_err(ContextError::EmbedQuery)?
158        .into_iter()
159        .next()
160        .ok_or(ContextError::MissingQueryVector)?;
161
162    assemble_context_with_vector(db, request, &query_vector)
163}
164
165pub fn assemble_context_with_vector(
166    db: &Database,
167    request: ContextRequest,
168    query_vector: &[f32],
169) -> Result<ContextPack> {
170    let anchors = context_anchors(&request)?;
171    let route = RouteDecision {
172        wing: None,
173        room: None,
174        confidence: 0.0,
175        reason: "mind-model context assembly".to_string(),
176    };
177
178    let mut sections = Vec::new();
179    let mut remaining = request.max_items;
180    let mut seen = BTreeSet::new();
181
182    for tier in tier_order() {
183        if remaining == 0 {
184            break;
185        }
186        let mut tier_remaining = if matches!(tier, KnowledgeTier::DaoTian) {
187            request.dao_tian_limit.min(remaining)
188        } else {
189            remaining
190        };
191        if tier_remaining == 0 {
192            continue;
193        }
194        let mut items = Vec::new();
195        for anchor in &anchors {
196            if remaining == 0 || tier_remaining == 0 {
197                break;
198            }
199            for status in active_statuses() {
200                if remaining == 0 || tier_remaining == 0 {
201                    break;
202                }
203                let mut results = search_context_candidates(
204                    db,
205                    CandidateQuery {
206                        request: &request,
207                        query_vector,
208                        route: &route,
209                        anchor,
210                        memory_kind: MemoryKind::Knowledge,
211                        tier: Some(tier.clone()),
212                        status: Some(status.clone()),
213                        top_k: tier_remaining,
214                    },
215                )?;
216                results.retain(|result| result.anchor_id == anchor.anchor_id);
217                for result in results {
218                    if remaining == 0 || tier_remaining == 0 {
219                        break;
220                    }
221                    if !seen.insert(result.drawer_id.clone()) {
222                        continue;
223                    }
224                    items.push(context_item_from_result(db, result)?);
225                    remaining -= 1;
226                    tier_remaining -= 1;
227                }
228            }
229        }
230        if !items.is_empty() {
231            if request.include_cards && remaining > 0 {
232                append_card_context_items(
233                    db,
234                    &request,
235                    &anchors,
236                    tier,
237                    CardAppendState {
238                        seen: &mut seen,
239                        items: &mut items,
240                        remaining: &mut remaining,
241                        tier_remaining: &mut tier_remaining,
242                    },
243                )?;
244            }
245            sections.push(ContextSection {
246                name: tier_slug(tier).to_string(),
247                items,
248            });
249        } else if request.include_cards && remaining > 0 {
250            append_card_context_items(
251                db,
252                &request,
253                &anchors,
254                tier,
255                CardAppendState {
256                    seen: &mut seen,
257                    items: &mut items,
258                    remaining: &mut remaining,
259                    tier_remaining: &mut tier_remaining,
260                },
261            )?;
262            if !items.is_empty() {
263                sections.push(ContextSection {
264                    name: tier_slug(tier).to_string(),
265                    items,
266                });
267            }
268        }
269    }
270
271    if request.include_evidence && remaining > 0 {
272        let mut items = Vec::new();
273        for anchor in &anchors {
274            if remaining == 0 {
275                break;
276            }
277            let mut results = search_context_candidates(
278                db,
279                CandidateQuery {
280                    request: &request,
281                    query_vector,
282                    route: &route,
283                    anchor,
284                    memory_kind: MemoryKind::Evidence,
285                    tier: None,
286                    status: None,
287                    top_k: remaining,
288                },
289            )?;
290            results.retain(|result| result.anchor_id == anchor.anchor_id);
291            for result in results {
292                if remaining == 0 {
293                    break;
294                }
295                if !seen.insert(result.drawer_id.clone()) {
296                    continue;
297                }
298                items.push(context_item_from_result(db, result)?);
299                remaining -= 1;
300            }
301        }
302        if !items.is_empty() {
303            sections.push(ContextSection {
304                name: "evidence".to_string(),
305                items,
306            });
307        }
308    }
309
310    let distill_suggestions = if request.include_distill_suggestions {
311        detect_distill_suggestions(db)?
312    } else {
313        Vec::new()
314    };
315
316    Ok(ContextPack {
317        query: request.query,
318        domain: request.domain,
319        field: request.field,
320        anchors: anchors
321            .into_iter()
322            .map(|anchor| ContextAnchor {
323                anchor_kind: anchor.anchor_kind,
324                anchor_id: anchor.anchor_id,
325            })
326            .collect(),
327        sections,
328        distill_suggestions,
329    })
330}
331
332/// P106 detector: deterministic, read-only. Flags each `field` whose active
333/// evidence count is at least `DISTILL_SIGNAL_MIN_EVIDENCE` AND which has zero
334/// active promoted-or-canonical knowledge. Returns at most
335/// `DISTILL_SIGNAL_MAX_SUGGESTIONS`, ordered by descending evidence count then
336/// ascending field. Performs no database writes and no LLM call.
337fn detect_distill_suggestions(db: &Database) -> Result<Vec<DistillSuggestion>> {
338    let mut qualifying: Vec<(String, i64)> = db
339        .distill_field_counts()
340        .map_err(ContextError::LoadDrawer)?
341        .into_iter()
342        .filter(|(_, evidence_count, promoted_count)| {
343            *evidence_count >= DISTILL_SIGNAL_MIN_EVIDENCE && *promoted_count == 0
344        })
345        .map(|(field, evidence_count, _)| (field, evidence_count))
346        .collect();
347
348    qualifying.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
349    qualifying.truncate(DISTILL_SIGNAL_MAX_SUGGESTIONS);
350
351    let mut suggestions = Vec::with_capacity(qualifying.len());
352    for (field, evidence_count) in qualifying {
353        let sample_evidence_drawer_ids = db
354            .sample_evidence_drawer_ids(&field, DISTILL_SIGNAL_SAMPLE_LIMIT)
355            .map_err(ContextError::LoadDrawer)?;
356        suggestions.push(DistillSuggestion {
357            field,
358            evidence_count: evidence_count as usize,
359            sample_evidence_drawer_ids,
360            suggested_tier: "dao_ren".to_string(),
361        });
362    }
363    Ok(suggestions)
364}
365
366fn context_anchors(request: &ContextRequest) -> Result<Vec<AnchorCandidate>> {
367    let derived = anchor::derive_anchor_from_cwd(Some(&request.cwd))?;
368    let mut anchors = Vec::new();
369    anchors.push(AnchorCandidate {
370        anchor_kind: AnchorKind::Worktree,
371        anchor_id: derived.anchor_id,
372        domain: request.domain.clone(),
373    });
374
375    let repo_anchor_id = derived
376        .parent_anchor_id
377        .unwrap_or_else(|| anchor::LEGACY_REPO_ANCHOR_ID.to_string());
378    anchors.push(AnchorCandidate {
379        anchor_kind: AnchorKind::Repo,
380        anchor_id: repo_anchor_id,
381        domain: request.domain.clone(),
382    });
383
384    // P12 backfilled existing drawers to repo://legacy. Keep it as a fallback
385    // so the first runtime assembler remains useful on pre-anchor databases.
386    anchors.push(AnchorCandidate {
387        anchor_kind: AnchorKind::Repo,
388        anchor_id: anchor::LEGACY_REPO_ANCHOR_ID.to_string(),
389        domain: request.domain.clone(),
390    });
391
392    anchors.push(AnchorCandidate {
393        anchor_kind: AnchorKind::Global,
394        anchor_id: "global://default".to_string(),
395        domain: MemoryDomain::Global,
396    });
397
398    Ok(dedup_anchors(anchors))
399}
400
401fn dedup_anchors(anchors: Vec<AnchorCandidate>) -> Vec<AnchorCandidate> {
402    let mut seen = BTreeSet::new();
403    anchors
404        .into_iter()
405        .filter(|anchor| {
406            seen.insert((
407                anchor_kind_slug(&anchor.anchor_kind).to_string(),
408                anchor.anchor_id.clone(),
409            ))
410        })
411        .collect()
412}
413
414fn search_context_candidates(
415    db: &Database,
416    query: CandidateQuery<'_>,
417) -> Result<Vec<SearchResult>> {
418    let filters = SearchFilters {
419        memory_kind: Some(memory_kind_slug(&query.memory_kind).to_string()),
420        domain: Some(domain_slug(&query.anchor.domain).to_string()),
421        field: Some(query.request.field.clone()),
422        tier: query.tier.as_ref().map(tier_slug).map(str::to_string),
423        status: query.status.as_ref().map(status_slug).map(str::to_string),
424        anchor_kind: Some(anchor_kind_slug(&query.anchor.anchor_kind).to_string()),
425    };
426
427    search_with_vector_options(
428        db,
429        &query.request.query,
430        query.query_vector,
431        query.route.clone(),
432        SearchOptions {
433            filters,
434            with_neighbors: false,
435        },
436        query.top_k,
437    )
438    .map_err(ContextError::Search)
439}
440
441fn context_item_from_result(db: &Database, result: SearchResult) -> Result<ContextItem> {
442    let trigger_hints = db
443        .get_drawer(&result.drawer_id)
444        .map_err(ContextError::LoadDrawer)?
445        .and_then(|drawer| drawer.trigger_hints);
446    let text = match result.memory_kind {
447        MemoryKind::Knowledge => result
448            .statement
449            .as_deref()
450            .map(str::trim)
451            .filter(|value| !value.is_empty())
452            .unwrap_or(result.content.as_str())
453            .to_string(),
454        MemoryKind::Evidence => result.content,
455    };
456    Ok(ContextItem {
457        drawer_id: result.drawer_id,
458        source_file: result.source_file,
459        text,
460        tier: result.tier,
461        status: result.status,
462        anchor_kind: result.anchor_kind,
463        anchor_id: result.anchor_id,
464        parent_anchor_id: result.parent_anchor_id,
465        trigger_hints,
466        card_id: None,
467        evidence_citations: Vec::new(),
468    })
469}
470
471fn append_card_context_items(
472    db: &Database,
473    request: &ContextRequest,
474    anchors: &[AnchorCandidate],
475    tier: &KnowledgeTier,
476    state: CardAppendState<'_>,
477) -> Result<()> {
478    for anchor in anchors {
479        if *state.remaining == 0 || *state.tier_remaining == 0 {
480            break;
481        }
482        for status in active_statuses() {
483            if *state.remaining == 0 || *state.tier_remaining == 0 {
484                break;
485            }
486            let cards = db
487                .list_knowledge_cards(&KnowledgeCardFilter {
488                    tier: Some(tier.clone()),
489                    status: Some(status.clone()),
490                    domain: Some(anchor.domain.clone()),
491                    field: Some(request.field.clone()),
492                    anchor_kind: Some(anchor.anchor_kind.clone()),
493                    anchor_id: Some(anchor.anchor_id.clone()),
494                })
495                .map_err(ContextError::LoadCard)?;
496            for card in cards {
497                if *state.remaining == 0 || *state.tier_remaining == 0 {
498                    break;
499                }
500                let seen_key = format!("card:{}", card.id);
501                if !state.seen.insert(seen_key) {
502                    continue;
503                }
504                state.items.push(context_item_from_card(db, card)?);
505                *state.remaining -= 1;
506                *state.tier_remaining -= 1;
507            }
508        }
509    }
510    Ok(())
511}
512
513fn context_item_from_card(db: &Database, card: KnowledgeCard) -> Result<ContextItem> {
514    let evidence_citations = db
515        .knowledge_evidence_links(&card.id)
516        .map_err(ContextError::LoadCard)?
517        .into_iter()
518        .map(|link| evidence_citation_from_link(db, link))
519        .collect::<Result<Vec<_>>>()?;
520    Ok(ContextItem {
521        drawer_id: card.id.clone(),
522        source_file: format!("knowledge-card://{}", card.id),
523        text: card.statement.clone(),
524        card_id: Some(card.id),
525        tier: Some(card.tier),
526        status: Some(card.status),
527        anchor_kind: card.anchor_kind,
528        anchor_id: card.anchor_id,
529        parent_anchor_id: card.parent_anchor_id,
530        trigger_hints: card.trigger_hints,
531        evidence_citations,
532    })
533}
534
535fn evidence_citation_from_link(
536    db: &Database,
537    link: KnowledgeEvidenceLink,
538) -> Result<ContextEvidenceCitation> {
539    let source_file = db
540        .get_drawer(&link.evidence_drawer_id)
541        .map_err(ContextError::LoadDrawer)?
542        .and_then(|drawer| drawer.source_file)
543        .unwrap_or_else(|| format!("drawer://{}", link.evidence_drawer_id));
544    Ok(ContextEvidenceCitation {
545        evidence_drawer_id: link.evidence_drawer_id,
546        role: link.role,
547        source_file,
548    })
549}
550
551fn tier_order() -> &'static [KnowledgeTier] {
552    &[
553        KnowledgeTier::DaoTian,
554        KnowledgeTier::DaoRen,
555        KnowledgeTier::Shu,
556        KnowledgeTier::Qi,
557    ]
558}
559
560fn active_statuses() -> &'static [KnowledgeStatus] {
561    &[KnowledgeStatus::Canonical, KnowledgeStatus::Promoted]
562}
563
564fn memory_kind_slug(value: &MemoryKind) -> &'static str {
565    match value {
566        MemoryKind::Evidence => "evidence",
567        MemoryKind::Knowledge => "knowledge",
568    }
569}
570
571fn domain_slug(value: &MemoryDomain) -> &'static str {
572    match value {
573        MemoryDomain::Project => "project",
574        MemoryDomain::Agent => "agent",
575        MemoryDomain::Skill => "skill",
576        MemoryDomain::Global => "global",
577    }
578}
579
580fn tier_slug(value: &KnowledgeTier) -> &'static str {
581    match value {
582        KnowledgeTier::Qi => "qi",
583        KnowledgeTier::Shu => "shu",
584        KnowledgeTier::DaoRen => "dao_ren",
585        KnowledgeTier::DaoTian => "dao_tian",
586    }
587}
588
589fn status_slug(value: &KnowledgeStatus) -> &'static str {
590    match value {
591        KnowledgeStatus::Candidate => "candidate",
592        KnowledgeStatus::Promoted => "promoted",
593        KnowledgeStatus::Canonical => "canonical",
594        KnowledgeStatus::Demoted => "demoted",
595        KnowledgeStatus::Retired => "retired",
596    }
597}
598
599fn anchor_kind_slug(value: &AnchorKind) -> &'static str {
600    match value {
601        AnchorKind::Global => "global",
602        AnchorKind::Repo => "repo",
603        AnchorKind::Worktree => "worktree",
604    }
605}