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 pub include_distill_suggestions: bool,
53}
54
55pub 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 pub distill_suggestions: Vec<DistillSuggestion>,
77}
78
79#[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
332fn 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 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}