Skip to main content

mempal/
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}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
53pub struct ContextAnchor {
54    pub anchor_kind: AnchorKind,
55    pub anchor_id: String,
56}
57
58#[derive(Debug, Clone, Serialize)]
59pub struct ContextPack {
60    pub query: String,
61    pub domain: MemoryDomain,
62    pub field: String,
63    pub anchors: Vec<ContextAnchor>,
64    pub sections: Vec<ContextSection>,
65}
66
67#[derive(Debug, Clone, Serialize)]
68pub struct ContextSection {
69    pub name: String,
70    pub items: Vec<ContextItem>,
71}
72
73#[derive(Debug, Clone, Serialize)]
74pub struct ContextItem {
75    pub drawer_id: String,
76    pub source_file: String,
77    pub text: String,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub card_id: Option<String>,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub tier: Option<KnowledgeTier>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub status: Option<KnowledgeStatus>,
84    pub anchor_kind: AnchorKind,
85    pub anchor_id: String,
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub parent_anchor_id: Option<String>,
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub trigger_hints: Option<TriggerHints>,
90    #[serde(skip_serializing_if = "Vec::is_empty", default)]
91    pub evidence_citations: Vec<ContextEvidenceCitation>,
92}
93
94#[derive(Debug, Clone, Serialize)]
95pub struct ContextEvidenceCitation {
96    pub evidence_drawer_id: String,
97    pub role: KnowledgeEvidenceRole,
98    pub source_file: String,
99}
100
101#[derive(Debug, Clone)]
102struct AnchorCandidate {
103    anchor_kind: AnchorKind,
104    anchor_id: String,
105    domain: MemoryDomain,
106}
107
108#[derive(Debug, Clone)]
109struct CandidateQuery<'a> {
110    request: &'a ContextRequest,
111    query_vector: &'a [f32],
112    route: &'a RouteDecision,
113    anchor: &'a AnchorCandidate,
114    memory_kind: MemoryKind,
115    tier: Option<KnowledgeTier>,
116    status: Option<KnowledgeStatus>,
117    top_k: usize,
118}
119
120struct CardAppendState<'a> {
121    seen: &'a mut BTreeSet<String>,
122    items: &'a mut Vec<ContextItem>,
123    remaining: &'a mut usize,
124    tier_remaining: &'a mut usize,
125}
126
127pub async fn assemble_context<E: Embedder + ?Sized>(
128    db: &Database,
129    embedder: &E,
130    request: ContextRequest,
131) -> Result<ContextPack> {
132    let query_vector = embedder
133        .embed(&[request.query.as_str()])
134        .await
135        .map_err(ContextError::EmbedQuery)?
136        .into_iter()
137        .next()
138        .ok_or(ContextError::MissingQueryVector)?;
139
140    assemble_context_with_vector(db, request, &query_vector)
141}
142
143pub fn assemble_context_with_vector(
144    db: &Database,
145    request: ContextRequest,
146    query_vector: &[f32],
147) -> Result<ContextPack> {
148    let anchors = context_anchors(&request)?;
149    let route = RouteDecision {
150        wing: None,
151        room: None,
152        confidence: 0.0,
153        reason: "mind-model context assembly".to_string(),
154    };
155
156    let mut sections = Vec::new();
157    let mut remaining = request.max_items;
158    let mut seen = BTreeSet::new();
159
160    for tier in tier_order() {
161        if remaining == 0 {
162            break;
163        }
164        let mut tier_remaining = if matches!(tier, KnowledgeTier::DaoTian) {
165            request.dao_tian_limit.min(remaining)
166        } else {
167            remaining
168        };
169        if tier_remaining == 0 {
170            continue;
171        }
172        let mut items = Vec::new();
173        for anchor in &anchors {
174            if remaining == 0 || tier_remaining == 0 {
175                break;
176            }
177            for status in active_statuses() {
178                if remaining == 0 || tier_remaining == 0 {
179                    break;
180                }
181                let mut results = search_context_candidates(
182                    db,
183                    CandidateQuery {
184                        request: &request,
185                        query_vector,
186                        route: &route,
187                        anchor,
188                        memory_kind: MemoryKind::Knowledge,
189                        tier: Some(tier.clone()),
190                        status: Some(status.clone()),
191                        top_k: tier_remaining,
192                    },
193                )?;
194                results.retain(|result| result.anchor_id == anchor.anchor_id);
195                for result in results {
196                    if remaining == 0 || tier_remaining == 0 {
197                        break;
198                    }
199                    if !seen.insert(result.drawer_id.clone()) {
200                        continue;
201                    }
202                    items.push(context_item_from_result(db, result)?);
203                    remaining -= 1;
204                    tier_remaining -= 1;
205                }
206            }
207        }
208        if !items.is_empty() {
209            if request.include_cards && remaining > 0 {
210                append_card_context_items(
211                    db,
212                    &request,
213                    &anchors,
214                    tier,
215                    CardAppendState {
216                        seen: &mut seen,
217                        items: &mut items,
218                        remaining: &mut remaining,
219                        tier_remaining: &mut tier_remaining,
220                    },
221                )?;
222            }
223            sections.push(ContextSection {
224                name: tier_slug(tier).to_string(),
225                items,
226            });
227        } else if request.include_cards && remaining > 0 {
228            append_card_context_items(
229                db,
230                &request,
231                &anchors,
232                tier,
233                CardAppendState {
234                    seen: &mut seen,
235                    items: &mut items,
236                    remaining: &mut remaining,
237                    tier_remaining: &mut tier_remaining,
238                },
239            )?;
240            if !items.is_empty() {
241                sections.push(ContextSection {
242                    name: tier_slug(tier).to_string(),
243                    items,
244                });
245            }
246        }
247    }
248
249    if request.include_evidence && remaining > 0 {
250        let mut items = Vec::new();
251        for anchor in &anchors {
252            if remaining == 0 {
253                break;
254            }
255            let mut results = search_context_candidates(
256                db,
257                CandidateQuery {
258                    request: &request,
259                    query_vector,
260                    route: &route,
261                    anchor,
262                    memory_kind: MemoryKind::Evidence,
263                    tier: None,
264                    status: None,
265                    top_k: remaining,
266                },
267            )?;
268            results.retain(|result| result.anchor_id == anchor.anchor_id);
269            for result in results {
270                if remaining == 0 {
271                    break;
272                }
273                if !seen.insert(result.drawer_id.clone()) {
274                    continue;
275                }
276                items.push(context_item_from_result(db, result)?);
277                remaining -= 1;
278            }
279        }
280        if !items.is_empty() {
281            sections.push(ContextSection {
282                name: "evidence".to_string(),
283                items,
284            });
285        }
286    }
287
288    Ok(ContextPack {
289        query: request.query,
290        domain: request.domain,
291        field: request.field,
292        anchors: anchors
293            .into_iter()
294            .map(|anchor| ContextAnchor {
295                anchor_kind: anchor.anchor_kind,
296                anchor_id: anchor.anchor_id,
297            })
298            .collect(),
299        sections,
300    })
301}
302
303fn context_anchors(request: &ContextRequest) -> Result<Vec<AnchorCandidate>> {
304    let derived = anchor::derive_anchor_from_cwd(Some(&request.cwd))?;
305    let mut anchors = Vec::new();
306    anchors.push(AnchorCandidate {
307        anchor_kind: AnchorKind::Worktree,
308        anchor_id: derived.anchor_id,
309        domain: request.domain.clone(),
310    });
311
312    let repo_anchor_id = derived
313        .parent_anchor_id
314        .unwrap_or_else(|| anchor::LEGACY_REPO_ANCHOR_ID.to_string());
315    anchors.push(AnchorCandidate {
316        anchor_kind: AnchorKind::Repo,
317        anchor_id: repo_anchor_id,
318        domain: request.domain.clone(),
319    });
320
321    // P12 backfilled existing drawers to repo://legacy. Keep it as a fallback
322    // so the first runtime assembler remains useful on pre-anchor databases.
323    anchors.push(AnchorCandidate {
324        anchor_kind: AnchorKind::Repo,
325        anchor_id: anchor::LEGACY_REPO_ANCHOR_ID.to_string(),
326        domain: request.domain.clone(),
327    });
328
329    anchors.push(AnchorCandidate {
330        anchor_kind: AnchorKind::Global,
331        anchor_id: "global://default".to_string(),
332        domain: MemoryDomain::Global,
333    });
334
335    Ok(dedup_anchors(anchors))
336}
337
338fn dedup_anchors(anchors: Vec<AnchorCandidate>) -> Vec<AnchorCandidate> {
339    let mut seen = BTreeSet::new();
340    anchors
341        .into_iter()
342        .filter(|anchor| {
343            seen.insert((
344                anchor_kind_slug(&anchor.anchor_kind).to_string(),
345                anchor.anchor_id.clone(),
346            ))
347        })
348        .collect()
349}
350
351fn search_context_candidates(
352    db: &Database,
353    query: CandidateQuery<'_>,
354) -> Result<Vec<SearchResult>> {
355    let filters = SearchFilters {
356        memory_kind: Some(memory_kind_slug(&query.memory_kind).to_string()),
357        domain: Some(domain_slug(&query.anchor.domain).to_string()),
358        field: Some(query.request.field.clone()),
359        tier: query.tier.as_ref().map(tier_slug).map(str::to_string),
360        status: query.status.as_ref().map(status_slug).map(str::to_string),
361        anchor_kind: Some(anchor_kind_slug(&query.anchor.anchor_kind).to_string()),
362    };
363
364    search_with_vector_options(
365        db,
366        &query.request.query,
367        query.query_vector,
368        query.route.clone(),
369        SearchOptions {
370            filters,
371            with_neighbors: false,
372        },
373        query.top_k,
374    )
375    .map_err(ContextError::Search)
376}
377
378fn context_item_from_result(db: &Database, result: SearchResult) -> Result<ContextItem> {
379    let trigger_hints = db
380        .get_drawer(&result.drawer_id)
381        .map_err(ContextError::LoadDrawer)?
382        .and_then(|drawer| drawer.trigger_hints);
383    let text = match result.memory_kind {
384        MemoryKind::Knowledge => result
385            .statement
386            .as_deref()
387            .map(str::trim)
388            .filter(|value| !value.is_empty())
389            .unwrap_or(result.content.as_str())
390            .to_string(),
391        MemoryKind::Evidence => result.content,
392    };
393    Ok(ContextItem {
394        drawer_id: result.drawer_id,
395        source_file: result.source_file,
396        text,
397        tier: result.tier,
398        status: result.status,
399        anchor_kind: result.anchor_kind,
400        anchor_id: result.anchor_id,
401        parent_anchor_id: result.parent_anchor_id,
402        trigger_hints,
403        card_id: None,
404        evidence_citations: Vec::new(),
405    })
406}
407
408fn append_card_context_items(
409    db: &Database,
410    request: &ContextRequest,
411    anchors: &[AnchorCandidate],
412    tier: &KnowledgeTier,
413    state: CardAppendState<'_>,
414) -> Result<()> {
415    for anchor in anchors {
416        if *state.remaining == 0 || *state.tier_remaining == 0 {
417            break;
418        }
419        for status in active_statuses() {
420            if *state.remaining == 0 || *state.tier_remaining == 0 {
421                break;
422            }
423            let cards = db
424                .list_knowledge_cards(&KnowledgeCardFilter {
425                    tier: Some(tier.clone()),
426                    status: Some(status.clone()),
427                    domain: Some(anchor.domain.clone()),
428                    field: Some(request.field.clone()),
429                    anchor_kind: Some(anchor.anchor_kind.clone()),
430                    anchor_id: Some(anchor.anchor_id.clone()),
431                })
432                .map_err(ContextError::LoadCard)?;
433            for card in cards {
434                if *state.remaining == 0 || *state.tier_remaining == 0 {
435                    break;
436                }
437                let seen_key = format!("card:{}", card.id);
438                if !state.seen.insert(seen_key) {
439                    continue;
440                }
441                state.items.push(context_item_from_card(db, card)?);
442                *state.remaining -= 1;
443                *state.tier_remaining -= 1;
444            }
445        }
446    }
447    Ok(())
448}
449
450fn context_item_from_card(db: &Database, card: KnowledgeCard) -> Result<ContextItem> {
451    let evidence_citations = db
452        .knowledge_evidence_links(&card.id)
453        .map_err(ContextError::LoadCard)?
454        .into_iter()
455        .map(|link| evidence_citation_from_link(db, link))
456        .collect::<Result<Vec<_>>>()?;
457    Ok(ContextItem {
458        drawer_id: card.id.clone(),
459        source_file: format!("knowledge-card://{}", card.id),
460        text: card.statement.clone(),
461        card_id: Some(card.id),
462        tier: Some(card.tier),
463        status: Some(card.status),
464        anchor_kind: card.anchor_kind,
465        anchor_id: card.anchor_id,
466        parent_anchor_id: card.parent_anchor_id,
467        trigger_hints: card.trigger_hints,
468        evidence_citations,
469    })
470}
471
472fn evidence_citation_from_link(
473    db: &Database,
474    link: KnowledgeEvidenceLink,
475) -> Result<ContextEvidenceCitation> {
476    let source_file = db
477        .get_drawer(&link.evidence_drawer_id)
478        .map_err(ContextError::LoadDrawer)?
479        .and_then(|drawer| drawer.source_file)
480        .unwrap_or_else(|| format!("drawer://{}", link.evidence_drawer_id));
481    Ok(ContextEvidenceCitation {
482        evidence_drawer_id: link.evidence_drawer_id,
483        role: link.role,
484        source_file,
485    })
486}
487
488fn tier_order() -> &'static [KnowledgeTier] {
489    &[
490        KnowledgeTier::DaoTian,
491        KnowledgeTier::DaoRen,
492        KnowledgeTier::Shu,
493        KnowledgeTier::Qi,
494    ]
495}
496
497fn active_statuses() -> &'static [KnowledgeStatus] {
498    &[KnowledgeStatus::Canonical, KnowledgeStatus::Promoted]
499}
500
501fn memory_kind_slug(value: &MemoryKind) -> &'static str {
502    match value {
503        MemoryKind::Evidence => "evidence",
504        MemoryKind::Knowledge => "knowledge",
505    }
506}
507
508fn domain_slug(value: &MemoryDomain) -> &'static str {
509    match value {
510        MemoryDomain::Project => "project",
511        MemoryDomain::Agent => "agent",
512        MemoryDomain::Skill => "skill",
513        MemoryDomain::Global => "global",
514    }
515}
516
517fn tier_slug(value: &KnowledgeTier) -> &'static str {
518    match value {
519        KnowledgeTier::Qi => "qi",
520        KnowledgeTier::Shu => "shu",
521        KnowledgeTier::DaoRen => "dao_ren",
522        KnowledgeTier::DaoTian => "dao_tian",
523    }
524}
525
526fn status_slug(value: &KnowledgeStatus) -> &'static str {
527    match value {
528        KnowledgeStatus::Candidate => "candidate",
529        KnowledgeStatus::Promoted => "promoted",
530        KnowledgeStatus::Canonical => "canonical",
531        KnowledgeStatus::Demoted => "demoted",
532        KnowledgeStatus::Retired => "retired",
533    }
534}
535
536fn anchor_kind_slug(value: &AnchorKind) -> &'static str {
537    match value {
538        AnchorKind::Global => "global",
539        AnchorKind::Repo => "repo",
540        AnchorKind::Worktree => "worktree",
541    }
542}