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 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}