1use std::sync::Mutex;
4
5use async_trait::async_trait;
6use serde_json::Value;
7
8use khive_runtime::pack::PackRuntime;
9use khive_runtime::{KhiveRuntime, NamespaceToken, RuntimeError, VerbRegistry};
10use khive_types::{HandlerDef, Pack, ParamDef, VerbCategory, Visibility};
11
12use khive_brain_core::BalancedRecallState;
13
14use crate::ann::{new_shared, SharedAnn};
15use crate::config::RecallConfig;
16use crate::query_cache::QueryEmbeddingCache;
17
18pub struct MemoryPack {
20 pub(crate) runtime: KhiveRuntime,
21 pub(crate) config: Mutex<RecallConfig>,
23 pub(crate) ann: SharedAnn,
25 pub(crate) query_cache: QueryEmbeddingCache,
27 pub(crate) recall_state: Mutex<BalancedRecallState>,
34 pub(crate) brain_profile: Option<String>,
40}
41
42impl MemoryPack {
43 pub(crate) fn active_config(&self) -> RecallConfig {
47 self.config.lock().unwrap().clone()
48 }
49
50 pub fn new(runtime: KhiveRuntime) -> Self {
52 let brain_profile = runtime.config().brain_profile.clone();
53 Self {
54 runtime,
55 config: Mutex::new(RecallConfig::default()),
56 ann: new_shared(),
57 query_cache: QueryEmbeddingCache::with_default_capacity(),
58 recall_state: Mutex::new(BalancedRecallState::new(10_000)),
59 brain_profile,
60 }
61 }
62
63 #[cfg(test)]
64 pub(crate) fn ann_for_test(&self) -> SharedAnn {
65 self.ann.clone()
66 }
67}
68
69impl Pack for MemoryPack {
70 const NAME: &'static str = "memory";
71 const NOTE_KINDS: &'static [&'static str] = &["memory"];
72 const ENTITY_KINDS: &'static [&'static str] = &[];
73 const HANDLERS: &'static [HandlerDef] = &MEMORY_HANDLERS;
74 const REQUIRES: &'static [&'static str] = &["kg"];
75}
76
77static MEMORY_HANDLERS: [HandlerDef; 10] = [
81 HandlerDef {
83 name: "memory.remember",
84 description: "Create a memory note with salience and decay",
85 visibility: Visibility::Verb,
86 category: VerbCategory::Commissive,
87 params: &[
88 ParamDef {
89 name: "content",
90 param_type: "string",
91 required: true,
92 description: "Memory content to store.",
93 },
94 ParamDef {
95 name: "salience",
96 param_type: "number",
97 required: false,
98 description: "Salience weight 0.0–1.0. Default is type-differentiated: episodic=0.3, semantic=0.5.",
99 },
100 ParamDef {
101 name: "decay_factor",
102 param_type: "number",
103 required: false,
104 description: "Decay rate >= 0. Default is type-differentiated: episodic=0.02 (~35d half-life), semantic=0.005 (~139d half-life). Higher = faster decay.",
105 },
106 ParamDef {
107 name: "memory_type",
108 param_type: "string",
109 required: false,
110 description: "Memory type tag: \"episodic\" | \"semantic\" (default \"episodic\"). Other values are rejected.",
111 },
112 ParamDef {
113 name: "source_id",
114 param_type: "string",
115 required: false,
116 description: "UUID or 8-char short ID of the entity or note this memory annotates.",
117 },
118 ParamDef {
119 name: "embedding_model",
120 param_type: "string",
121 required: false,
122 description: "Model name for vector embedding (must be registered). Defaults to pack-configured model.",
123 },
124 ParamDef {
125 name: "tags",
126 param_type: "array",
127 required: false,
128 description: "Tag values to filter by. Matched against properties.tags on stored memories.",
129 },
130 ParamDef {
131 name: "namespace",
132 param_type: "string",
133 required: false,
134 description: "Write namespace override. When absent: episodic memories land in the actor's namespace; semantic memories land in \"local\". When present, overrides both routing rules.",
135 },
136 ],
137 },
138 HandlerDef {
140 name: "memory.feedback",
141 description: "Emit explicit feedback on a recalled entity; updates recall-domain posteriors",
142 visibility: Visibility::Verb,
143 category: VerbCategory::Commissive,
144 params: &[
145 ParamDef {
146 name: "target_id",
147 param_type: "string",
148 required: true,
149 description: "UUID of the recalled entity or memory being rated.",
150 },
151 ParamDef {
152 name: "signal",
153 param_type: "string",
154 required: true,
155 description: "Feedback signal: \"useful\" | \"not_useful\" | \"wrong\" | \"explicit_positive\" | \"explicit_negative\" | \"implicit_positive\" | \"implicit_negative\" | \"correction\".",
156 },
157 ],
158 },
159 HandlerDef {
161 name: "memory.recall",
162 description: "Recall memory notes with decay-aware hybrid ranking. Each hit carries resolved (read-model) values: memory_type defaults to \"episodic\" when not stored, salience and decay_factor reflect the effective defaults used for ranking.",
163 visibility: Visibility::Verb,
164 category: VerbCategory::Assertive,
165 params: &[
166 ParamDef {
167 name: "query",
168 param_type: "string",
169 required: true,
170 description: "Semantic recall query.",
171 },
172 ParamDef {
173 name: "limit",
174 param_type: "integer",
175 required: false,
176 description: "Maximum memories to return (default 10).",
177 },
178 ParamDef {
179 name: "top_k",
180 param_type: "integer",
181 required: false,
182 description: "Override result limit (max 100). Takes priority over limit.",
183 },
184 ParamDef {
185 name: "min_score",
186 param_type: "number",
187 required: false,
188 description: "Minimum composite score to include (default 0.0). Composite scores are always in [0,1]: relevance is normalized to [0,1] per strategy (RRF rank-1 → 1.0; Weighted scores are [0,1] natively), and all three weighted contributions sum to at most 1.0. Typical production floor: 0.3–0.7.",
189 },
190 ParamDef {
191 name: "score_floor",
192 param_type: "number",
193 required: false,
194 description: "Alias for min_score. Filter out hits below this composite score. Scores are always in [0,1] regardless of fusion strategy.",
195 },
196 ParamDef {
197 name: "min_salience",
198 param_type: "number",
199 required: false,
200 description: "Minimum salience score filter.",
201 },
202 ParamDef {
203 name: "memory_type",
204 param_type: "string",
205 required: false,
206 description: "Filter to this memory_type.",
207 },
208 ParamDef {
209 name: "fusion_strategy",
210 param_type: "string",
211 required: false,
212 description: "Fusion strategy: \"rrf\" | \"weighted\" | \"union\" | \"vector_only\" | \"keyword_only\". Weighted values come from pack config.",
213 },
214 ParamDef {
215 name: "embedding_model",
216 param_type: "string",
217 required: false,
218 description: "Model name for vector recall (must be registered). Defaults to pack-configured model.",
219 },
220 ParamDef {
221 name: "include_breakdown",
222 param_type: "boolean",
223 required: false,
224 description: "Include per-component score breakdowns in results.",
225 },
226 ParamDef {
227 name: "entity_names",
228 param_type: "array",
229 required: false,
230 description: "Entity names to boost in scoring. Memories mentioning these entities receive a 1.3× score multiplier.",
231 },
232 ParamDef {
233 name: "full_content",
234 param_type: "boolean",
235 required: false,
236 description: "When false, content is truncated to 200 chars in results. Default true.",
237 },
238 ParamDef {
239 name: "tags",
240 param_type: "array",
241 required: false,
242 description: "Filter results to memories whose stored tags include at least one (any) or all (all) of these values. Matched against properties.tags.",
243 },
244 ParamDef {
245 name: "tag_mode",
246 param_type: "string",
247 required: false,
248 description: "Tag filter mode: \"any\" (OR, default) or \"all\" (AND). Only applies when tags is non-empty.",
249 },
250 ],
251 },
252 HandlerDef {
253 name: "memory.recall_embed",
254 description: "Return the embedding vector used by memory recall",
255 visibility: Visibility::Subhandler,
256 category: VerbCategory::Assertive,
257 params: &[ParamDef {
258 name: "include_embeddings",
259 param_type: "boolean",
260 required: false,
261 description: "When true, include full embedding vector arrays in the response. Default false — only model name and dimension metadata are returned.",
262 }],
263 },
264 HandlerDef {
265 name: "memory.recall_candidates",
266 description: "Return raw memory recall candidates by retrieval source",
267 visibility: Visibility::Subhandler,
268 category: VerbCategory::Assertive,
269 params: &[],
270 },
271 HandlerDef {
272 name: "memory.recall_fuse",
273 description: "Return fused memory recall candidates before final scoring",
274 visibility: Visibility::Subhandler,
275 category: VerbCategory::Assertive,
276 params: &[],
277 },
278 HandlerDef {
280 name: "memory.recall_rerank",
281 description: "Apply configured rerankers to fused candidates",
282 visibility: Visibility::Subhandler,
283 category: VerbCategory::Assertive,
284 params: &[],
285 },
286 HandlerDef {
287 name: "memory.recall_score",
288 description: "Score a memory recall candidate and return score breakdown",
289 visibility: Visibility::Subhandler,
290 category: VerbCategory::Assertive,
291 params: &[],
292 },
293 HandlerDef {
295 name: "memory.prune",
296 description: "Soft-delete memories below a salience threshold and/or past expires_at. Curation-layer operation per ADR-014.",
297 visibility: Visibility::Verb,
298 category: VerbCategory::Commissive,
299 params: &[
300 ParamDef {
301 name: "min_salience",
302 param_type: "number",
303 required: false,
304 description: "Soft-delete memories with salience strictly below this value.",
305 },
306 ParamDef {
307 name: "before",
308 param_type: "integer",
309 required: false,
310 description: "Soft-delete memories expired at or before this Unix microsecond timestamp. Defaults to now. Pass 0 to skip expiry filter.",
311 },
312 ParamDef {
313 name: "namespace",
314 param_type: "string",
315 required: false,
316 description: "Namespace to prune. Defaults to \"local\".",
317 },
318 ParamDef {
319 name: "dry_run",
320 param_type: "boolean",
321 required: false,
322 description: "When true, count candidates without deleting. Default false.",
323 },
324 ],
325 },
326 HandlerDef {
328 name: "memory.vacuum",
329 description: "Run SQLite VACUUM to reclaim space freed by soft-deleted rows.",
330 visibility: Visibility::Verb,
331 category: VerbCategory::Commissive,
332 params: &[],
333 },
334];
335
336struct MemoryPackFactory;
339
340impl khive_runtime::PackFactory for MemoryPackFactory {
341 fn name(&self) -> &'static str {
342 "memory"
343 }
344
345 fn requires(&self) -> &'static [&'static str] {
346 &["kg"]
347 }
348
349 fn create(&self, runtime: KhiveRuntime) -> Box<dyn khive_runtime::PackRuntime> {
350 Box::new(MemoryPack::new(runtime))
351 }
352}
353
354inventory::submit! { khive_runtime::PackRegistration(&MemoryPackFactory) }
355
356#[async_trait]
357impl PackRuntime for MemoryPack {
358 fn name(&self) -> &str {
359 <MemoryPack as Pack>::NAME
360 }
361
362 fn note_kinds(&self) -> &'static [&'static str] {
363 <MemoryPack as Pack>::NOTE_KINDS
364 }
365
366 fn entity_kinds(&self) -> &'static [&'static str] {
367 <MemoryPack as Pack>::ENTITY_KINDS
368 }
369
370 fn handlers(&self) -> &'static [HandlerDef] {
371 &MEMORY_HANDLERS
372 }
373
374 fn requires(&self) -> &'static [&'static str] {
375 <MemoryPack as Pack>::REQUIRES
376 }
377
378 async fn warm(&self) {
379 crate::ann::warm_existing_memory_indexes(&self.runtime, &self.ann).await;
380 fts_population_guard(&self.runtime).await;
381 }
382
383 async fn dispatch(
384 &self,
385 verb: &str,
386 params: Value,
387 registry: &VerbRegistry,
388 token: &NamespaceToken,
389 ) -> Result<Value, RuntimeError> {
390 match verb {
391 "memory.remember" => self.handle_remember(token, params).await,
392 "memory.feedback" => self.handle_feedback(token, params, registry).await,
393 "memory.recall" => self.handle_recall(token, params, registry).await,
394 "memory.recall_embed" => self.handle_recall_embed(params).await,
395 "memory.recall_candidates" => self.handle_recall_candidates(token, params).await,
396 "memory.recall_fuse" => self.handle_recall_fuse(token, params, registry).await,
397 "memory.recall_rerank" => self.handle_recall_rerank(params).await,
398 "memory.recall_score" => self.handle_recall_score(params).await,
399 "memory.prune" => self.handle_prune(token, params).await,
400 "memory.vacuum" => self.handle_vacuum(params).await,
401 _ => Err(RuntimeError::InvalidInput(format!(
402 "memory pack does not handle verb {verb:?}"
403 ))),
404 }
405 }
406}
407
408async fn fts_population_guard(rt: &KhiveRuntime) {
418 use khive_storage::types::{SqlStatement, SqlValue};
419
420 let sql = rt.sql();
421
422 let Ok(mut reader) = sql.reader().await else {
423 tracing::warn!("fts_population_guard: could not open SQL reader — skipping check");
424 return;
425 };
426
427 for (base_table, fts_table) in [("notes", "fts_notes"), ("entities", "fts_entities")] {
428 let base_row = reader
429 .query_row(SqlStatement {
430 sql: format!("SELECT COUNT(*) AS cnt FROM {base_table} WHERE deleted_at IS NULL"),
431 params: vec![],
432 label: None,
433 })
434 .await;
435
436 let base_count: u64 = match base_row {
437 Ok(Some(r)) => match r.get("cnt") {
438 Some(SqlValue::Integer(n)) => *n as u64,
439 _ => 0,
440 },
441 _ => 0,
442 };
443
444 if base_count <= 100 {
445 continue;
446 }
447
448 let fts_row = reader
449 .query_row(SqlStatement {
450 sql: format!("SELECT COUNT(*) AS cnt FROM {fts_table}"),
451 params: vec![],
452 label: None,
453 })
454 .await;
455
456 let fts_count: u64 = match fts_row {
457 Ok(Some(r)) => match r.get("cnt") {
458 Some(SqlValue::Integer(n)) => *n as u64,
459 _ => 0,
460 },
461 _ => 0,
462 };
463
464 if fts_count < base_count / 2 {
465 tracing::warn!(
466 base_table,
467 fts_table,
468 base_count,
469 fts_count,
470 "FTS table is severely under-populated relative to base rows. \
471 FTS recall will return near-nothing. This is typically caused by \
472 a V3→V4 schema migration that did not run `kkernel reindex`. \
473 Fix: run `kkernel reindex --no-knowledge` to repopulate {fts_table}."
474 );
475 }
476 }
477}
478
479#[cfg(test)]
482mod ann_route_tests {
483 use super::*;
484 use std::sync::Arc;
485
486 use async_trait::async_trait;
487 use khive_pack_kg::KgPack;
488 use khive_runtime::{EmbedderProvider, Namespace, RuntimeConfig, VerbRegistryBuilder};
489 use lattice_embed::{EmbedError, EmbeddingModel, EmbeddingService};
490
491 struct HashVecService {
493 dims: usize,
494 }
495
496 fn fnv_to_vec(text: &str, dims: usize) -> Vec<f32> {
497 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
498 for b in text.bytes() {
499 h ^= b as u64;
500 h = h.wrapping_mul(0x0000_0001_0000_01b3);
501 }
502 let mut v = Vec::with_capacity(dims);
503 let mut s = h;
504 for _ in 0..dims {
505 s = s
506 .wrapping_mul(6_364_136_223_846_793_005)
507 .wrapping_add(1_442_695_040_888_963_407);
508 v.push(((s >> 33) as f32) / (0x7fff_ffff_u32 as f32) - 1.0);
509 }
510 v
511 }
512
513 #[async_trait]
514 impl EmbeddingService for HashVecService {
515 async fn embed(
516 &self,
517 texts: &[String],
518 _model: EmbeddingModel,
519 ) -> Result<Vec<Vec<f32>>, EmbedError> {
520 Ok(texts.iter().map(|t| fnv_to_vec(t, self.dims)).collect())
521 }
522
523 fn supports_model(&self, _model: EmbeddingModel) -> bool {
524 true
525 }
526
527 fn name(&self) -> &'static str {
528 "hash-vec"
529 }
530 }
531
532 struct HashVecProvider {
533 model_name: String,
534 dims: usize,
535 }
536
537 #[async_trait]
538 impl EmbedderProvider for HashVecProvider {
539 fn name(&self) -> &str {
540 &self.model_name
541 }
542
543 fn dimensions(&self) -> usize {
544 self.dims
545 }
546
547 async fn build(&self) -> Result<Arc<dyn EmbeddingService>, khive_runtime::RuntimeError> {
548 Ok(Arc::new(HashVecService { dims: self.dims }))
549 }
550 }
551
552 #[tokio::test]
570 async fn recall_second_call_uses_warm_ann_route() {
571 let tmp = tempfile::Builder::new()
572 .prefix("khive-memory-ann-route-")
573 .tempdir_in(std::env::temp_dir())
574 .expect("temp /tmp db dir");
575 let db_path = tmp.path().join("khive-graph.db");
576
577 const MODEL: &str = "ann-route-test-model";
578 const DIMS: usize = 32;
579
580 let rt = KhiveRuntime::new(RuntimeConfig {
581 db_path: Some(db_path),
582 embedding_model: None,
583 additional_embedding_models: vec![],
584 ..RuntimeConfig::default()
585 })
586 .expect("runtime");
587 rt.register_embedder(HashVecProvider {
588 model_name: MODEL.to_owned(),
589 dims: DIMS,
590 });
591
592 let ns = Namespace::parse("local").expect("local namespace");
593 let token = rt.authorize(ns).expect("authorize local");
594
595 for i in 0..32u32 {
599 rt.create_note_with_decay_for_embedding_model(
600 &token,
601 "memory",
602 None,
603 &format!("ann warm route note {i}"),
604 Some(0.7),
605 0.01,
606 None,
607 vec![],
608 None,
609 )
610 .await
611 .expect("create note");
612 }
613
614 let pack = MemoryPack::new(rt.clone());
615 let ann = pack.ann_for_test();
616
617 let mut builder = VerbRegistryBuilder::new();
618 builder.register(KgPack::new(rt.clone()));
619 builder.register(pack);
620 let registry = builder.build().expect("registry");
621
622 registry
625 .dispatch(
626 "memory.recall",
627 serde_json::json!({
628 "query": "ann warm route note 7",
629 "limit": 10
630 }),
631 )
632 .await
633 .expect("first recall");
634
635 ann.reset_warm_route_count();
636
637 registry
639 .dispatch(
640 "memory.recall",
641 serde_json::json!({
642 "query": "ann warm route note 7",
643 "limit": 10
644 }),
645 )
646 .await
647 .expect("second recall");
648
649 assert!(
650 ann.warm_route_count() > 0,
651 "second recall must route through warm ANN, not exact sqlite-vec fallback"
652 );
653 }
654}