1use super::SqliteStore;
5use crate::error::MemoryError;
6#[allow(unused_imports)]
7use zeph_db::{begin_write, sql};
8
9#[derive(Debug)]
11pub struct SkillUsageRow {
12 pub skill_name: String,
14 pub invocation_count: i64,
16 pub last_used_at: String,
18}
19
20#[derive(Debug)]
22pub struct SkillMetricsRow {
23 pub skill_name: String,
25 pub version_id: Option<i64>,
27 pub total: i64,
29 pub successes: i64,
31 pub failures: i64,
33}
34
35#[derive(Debug)]
37pub struct SkillVersionRow {
38 pub id: i64,
40 pub skill_name: String,
42 pub version: i64,
44 pub body: String,
46 pub description: String,
48 pub source: String,
50 pub is_active: bool,
52 pub success_count: i64,
54 pub failure_count: i64,
56 pub created_at: String,
58}
59
60type SkillVersionTuple = (
64 i64,
65 String,
66 i32,
67 String,
68 String,
69 String,
70 bool,
71 i32,
72 i32,
73 String,
74);
75
76fn skill_version_from_tuple(t: SkillVersionTuple) -> SkillVersionRow {
77 SkillVersionRow {
78 id: t.0,
79 skill_name: t.1,
80 version: i64::from(t.2),
81 body: t.3,
82 description: t.4,
83 source: t.5,
84 is_active: t.6,
85 success_count: i64::from(t.7),
86 failure_count: i64::from(t.8),
87 created_at: t.9,
88 }
89}
90
91impl SqliteStore {
92 #[tracing::instrument(skip_all, name = "memory.skills.record_skill_usage")]
101 pub async fn record_skill_usage(&self, skill_names: &[&str]) -> Result<(), MemoryError> {
102 let mut tx = begin_write(&self.pool).await?;
103 for name in skill_names {
104 zeph_db::query(sql!(
110 "INSERT INTO skill_usage (skill_name, invocation_count, last_used_at) \
111 VALUES (?, 1, CURRENT_TIMESTAMP) \
112 ON CONFLICT(skill_name) DO UPDATE SET \
113 invocation_count = skill_usage.invocation_count + 1, \
114 last_used_at = CURRENT_TIMESTAMP"
115 ))
116 .bind(name)
117 .execute(&mut *tx)
118 .await?;
119 }
120 tx.commit().await?;
121 Ok(())
122 }
123
124 #[tracing::instrument(skip_all, name = "memory.skills.load_skill_usage")]
130 pub async fn load_skill_usage(&self) -> Result<Vec<SkillUsageRow>, MemoryError> {
131 let last_used_at_sel =
136 <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("last_used_at");
137 let raw = format!(
138 "SELECT skill_name, invocation_count, {last_used_at_sel} \
139 FROM skill_usage ORDER BY invocation_count DESC"
140 );
141 let query_sql = zeph_db::rewrite_placeholders(&raw);
142 let rows: Vec<(String, i32, String)> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
143 .fetch_all(&self.pool)
144 .await?;
145
146 Ok(rows
147 .into_iter()
148 .map(
149 |(skill_name, invocation_count, last_used_at)| SkillUsageRow {
150 skill_name,
151 invocation_count: i64::from(invocation_count),
152 last_used_at,
153 },
154 )
155 .collect())
156 }
157
158 #[tracing::instrument(skip_all, name = "memory.skills.record_skill_outcome")]
164 pub async fn record_skill_outcome(
165 &self,
166 skill_name: &str,
167 version_id: Option<i64>,
168 conversation_id: Option<crate::types::ConversationId>,
169 outcome: &str,
170 error_context: Option<&str>,
171 outcome_detail: Option<&str>,
172 ) -> Result<(), MemoryError> {
173 zeph_db::query(sql!(
174 "INSERT INTO skill_outcomes \
175 (skill_name, version_id, conversation_id, outcome, error_context, outcome_detail) \
176 VALUES (?, ?, ?, ?, ?, ?)"
177 ))
178 .bind(skill_name)
179 .bind(version_id)
180 .bind(conversation_id)
181 .bind(outcome)
182 .bind(error_context)
183 .bind(outcome_detail)
184 .execute(&self.pool)
185 .await?;
186 Ok(())
187 }
188
189 #[tracing::instrument(skip_all, name = "memory.skills.record_skill_outcomes_batch")]
195 pub async fn record_skill_outcomes_batch(
196 &self,
197 skill_names: &[String],
198 conversation_id: Option<crate::types::ConversationId>,
199 outcome: &str,
200 error_context: Option<&str>,
201 outcome_detail: Option<&str>,
202 ) -> Result<(), MemoryError> {
203 let mut tx = begin_write(&self.pool).await?;
206
207 let mut version_map: std::collections::HashMap<String, Option<i64>> =
208 std::collections::HashMap::new();
209 for name in skill_names {
210 let vid: Option<(i64,)> = zeph_db::query_as(sql!(
211 "SELECT id FROM skill_versions WHERE skill_name = ? AND is_active = TRUE"
212 ))
213 .bind(name)
214 .fetch_optional(&mut *tx)
215 .await?;
216 version_map.insert(name.clone(), vid.map(|r| r.0));
217 }
218
219 for name in skill_names {
220 let version_id = version_map.get(name.as_str()).copied().flatten();
221 zeph_db::query(sql!(
222 "INSERT INTO skill_outcomes \
223 (skill_name, version_id, conversation_id, outcome, error_context, outcome_detail) \
224 VALUES (?, ?, ?, ?, ?, ?)"
225 ))
226 .bind(name)
227 .bind(version_id)
228 .bind(conversation_id)
229 .bind(outcome)
230 .bind(error_context)
231 .bind(outcome_detail)
232 .execute(&mut *tx)
233 .await?;
234 }
235 tx.commit().await?;
236 Ok(())
237 }
238
239 #[tracing::instrument(skip_all, name = "memory.skills.skill_metrics")]
245 pub async fn skill_metrics(
246 &self,
247 skill_name: &str,
248 ) -> Result<Option<SkillMetricsRow>, MemoryError> {
249 let row: Option<(String, Option<i64>, i64, i64, i64)> = zeph_db::query_as(sql!(
250 "SELECT skill_name, version_id, \
251 COUNT(*) as total, \
252 SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as successes, \
253 COUNT(*) - SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as failures \
254 FROM skill_outcomes WHERE skill_name = ? \
255 AND outcome NOT IN ('user_approval', 'user_rejection') \
256 GROUP BY skill_name, version_id \
257 ORDER BY version_id DESC LIMIT 1"
258 ))
259 .bind(skill_name)
260 .fetch_optional(&self.pool)
261 .await?;
262
263 Ok(row.map(
264 |(skill_name, version_id, total, successes, failures)| SkillMetricsRow {
265 skill_name,
266 version_id,
267 total,
268 successes,
269 failures,
270 },
271 ))
272 }
273
274 #[tracing::instrument(skip_all, name = "memory.skills.load_skill_outcome_stats")]
280 pub async fn load_skill_outcome_stats(&self) -> Result<Vec<SkillMetricsRow>, MemoryError> {
281 let rows: Vec<(String, Option<i64>, i64, i64, i64)> = zeph_db::query_as(sql!(
282 "SELECT skill_name, version_id, \
283 COUNT(*) as total, \
284 SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as successes, \
285 COUNT(*) - SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as failures \
286 FROM skill_outcomes \
287 GROUP BY skill_name \
288 ORDER BY total DESC"
289 ))
290 .fetch_all(&self.pool)
291 .await?;
292
293 Ok(rows
294 .into_iter()
295 .map(
296 |(skill_name, version_id, total, successes, failures)| SkillMetricsRow {
297 skill_name,
298 version_id,
299 total,
300 successes,
301 failures,
302 },
303 )
304 .collect())
305 }
306
307 #[allow(clippy::too_many_arguments)]
313 #[tracing::instrument(skip_all, name = "memory.skills.save_skill_version")]
315 pub async fn save_skill_version(
316 &self,
317 skill_name: &str,
318 version: i64,
319 body: &str,
320 description: &str,
321 source: &str,
322 error_context: Option<&str>,
323 predecessor_id: Option<i64>,
324 ) -> Result<i64, MemoryError> {
325 let row: (i64,) = zeph_db::query_as(sql!(
326 "INSERT INTO skill_versions \
327 (skill_name, version, body, description, source, error_context, predecessor_id) \
328 VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id"
329 ))
330 .bind(skill_name)
331 .bind(version)
332 .bind(body)
333 .bind(description)
334 .bind(source)
335 .bind(error_context)
336 .bind(predecessor_id)
337 .fetch_one(&self.pool)
338 .await?;
339 Ok(row.0)
340 }
341
342 #[allow(clippy::too_many_arguments)]
353 #[tracing::instrument(skip_all, name = "memory.skills.save_and_activate_skill_version")]
354 pub async fn save_and_activate_skill_version(
355 &self,
356 skill_name: &str,
357 version: i64,
358 body: &str,
359 description: &str,
360 source: &str,
361 error_context: Option<&str>,
362 predecessor_id: Option<i64>,
363 ) -> Result<i64, MemoryError> {
364 let mut tx = begin_write(&self.pool).await?;
365
366 let row: (i64,) = zeph_db::query_as(sql!(
367 "INSERT INTO skill_versions \
368 (skill_name, version, body, description, source, error_context, predecessor_id) \
369 VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id"
370 ))
371 .bind(skill_name)
372 .bind(version)
373 .bind(body)
374 .bind(description)
375 .bind(source)
376 .bind(error_context)
377 .bind(predecessor_id)
378 .fetch_one(&mut *tx)
379 .await?;
380 let id = row.0;
381
382 switch_active_skill_version(&mut tx, skill_name, id).await?;
383
384 tx.commit().await?;
385 Ok(id)
386 }
387
388 #[tracing::instrument(skip_all, name = "memory.skills.distinct_session_count")]
397 pub async fn distinct_session_count(&self, skill_name: &str) -> Result<i64, MemoryError> {
398 let row: (i64,) = zeph_db::query_as(sql!(
399 "SELECT COUNT(DISTINCT conversation_id) FROM skill_outcomes \
400 WHERE skill_name = ? AND conversation_id IS NOT NULL"
401 ))
402 .bind(skill_name)
403 .fetch_one(&self.pool)
404 .await?;
405 Ok(row.0)
406 }
407
408 #[tracing::instrument(skip_all, name = "memory.skills.active_skill_version")]
414 pub async fn active_skill_version(
415 &self,
416 skill_name: &str,
417 ) -> Result<Option<SkillVersionRow>, MemoryError> {
418 let created_at_sel =
421 <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
422 let raw = format!(
423 "SELECT id, skill_name, version, body, description, source, \
424 is_active, success_count, failure_count, {created_at_sel} \
425 FROM skill_versions WHERE skill_name = ? AND is_active = TRUE LIMIT 1"
426 );
427 let query_sql = zeph_db::rewrite_placeholders(&raw);
428 let row: Option<SkillVersionTuple> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
429 .bind(skill_name)
430 .fetch_optional(&self.pool)
431 .await?;
432
433 Ok(row.map(skill_version_from_tuple))
434 }
435
436 #[tracing::instrument(skip_all, name = "memory.skills.activate_skill_version")]
442 pub async fn activate_skill_version(
443 &self,
444 skill_name: &str,
445 version_id: i64,
446 ) -> Result<(), MemoryError> {
447 let mut tx = begin_write(&self.pool).await?;
448
449 switch_active_skill_version(&mut tx, skill_name, version_id).await?;
450
451 tx.commit().await?;
452 Ok(())
453 }
454
455 #[tracing::instrument(skip_all, name = "memory.skills.next_skill_version")]
461 pub async fn next_skill_version(&self, skill_name: &str) -> Result<i64, MemoryError> {
462 let row: (i64,) = zeph_db::query_as(sql!(
463 "SELECT COALESCE(MAX(version), 0) + 1 FROM skill_versions WHERE skill_name = ?"
464 ))
465 .bind(skill_name)
466 .fetch_one(&self.pool)
467 .await?;
468 Ok(row.0)
469 }
470
471 #[tracing::instrument(skip_all, name = "memory.skills.last_improvement_time")]
477 pub async fn last_improvement_time(
478 &self,
479 skill_name: &str,
480 ) -> Result<Option<String>, MemoryError> {
481 let created_at_sel =
483 <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
484 let raw = format!(
485 "SELECT {created_at_sel} FROM skill_versions \
486 WHERE skill_name = ? AND source = 'auto' \
487 ORDER BY id DESC LIMIT 1"
488 );
489 let query_sql = zeph_db::rewrite_placeholders(&raw);
490 let row: Option<(String,)> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
491 .bind(skill_name)
492 .fetch_optional(&self.pool)
493 .await?;
494 Ok(row.map(|r| r.0))
495 }
496
497 #[tracing::instrument(skip_all, name = "memory.skills.ensure_skill_version_exists")]
507 pub async fn ensure_skill_version_exists(
508 &self,
509 skill_name: &str,
510 body: &str,
511 description: &str,
512 ) -> Result<(), MemoryError> {
513 let mut tx = begin_write(&self.pool).await?;
514
515 let existing: Option<(i64,)> = zeph_db::query_as(sql!(
516 "SELECT id FROM skill_versions WHERE skill_name = ? LIMIT 1"
517 ))
518 .bind(skill_name)
519 .fetch_optional(&mut *tx)
520 .await?;
521
522 if existing.is_none() {
523 let row: (i64,) = zeph_db::query_as(sql!(
524 "INSERT INTO skill_versions \
525 (skill_name, version, body, description, source, error_context, predecessor_id) \
526 VALUES (?, 1, ?, ?, 'manual', NULL, NULL) RETURNING id"
527 ))
528 .bind(skill_name)
529 .bind(body)
530 .bind(description)
531 .fetch_one(&mut *tx)
532 .await?;
533 let id = row.0;
534
535 switch_active_skill_version(&mut tx, skill_name, id).await?;
536 }
537
538 tx.commit().await?;
539 Ok(())
540 }
541
542 #[tracing::instrument(skip_all, name = "memory.skills.load_skill_versions")]
548 pub async fn load_skill_versions(
549 &self,
550 skill_name: &str,
551 ) -> Result<Vec<SkillVersionRow>, MemoryError> {
552 let created_at_sel =
554 <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
555 let raw = format!(
556 "SELECT id, skill_name, version, body, description, source, \
557 is_active, success_count, failure_count, {created_at_sel} \
558 FROM skill_versions WHERE skill_name = ? ORDER BY version ASC"
559 );
560 let query_sql = zeph_db::rewrite_placeholders(&raw);
561 let rows: Vec<SkillVersionTuple> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
562 .bind(skill_name)
563 .fetch_all(&self.pool)
564 .await?;
565
566 Ok(rows.into_iter().map(skill_version_from_tuple).collect())
567 }
568
569 #[tracing::instrument(skip_all, name = "memory.skills.count_auto_versions")]
575 pub async fn count_auto_versions(&self, skill_name: &str) -> Result<i64, MemoryError> {
576 let row: (i64,) = zeph_db::query_as(sql!(
577 "SELECT COUNT(*) FROM skill_versions WHERE skill_name = ? AND source = 'auto'"
578 ))
579 .bind(skill_name)
580 .fetch_one(&self.pool)
581 .await?;
582 Ok(row.0)
583 }
584
585 #[cfg(feature = "sqlite")]
592 #[tracing::instrument(skip_all, name = "memory.skills.prune_skill_versions")]
593 pub async fn prune_skill_versions(
594 &self,
595 skill_name: &str,
596 max_versions: u32,
597 ) -> Result<u32, MemoryError> {
598 let result = zeph_db::query(sql!(
599 "DELETE FROM skill_versions WHERE id IN (\
600 SELECT id FROM skill_versions \
601 WHERE skill_name = ? AND source = 'auto' AND is_active = FALSE \
602 ORDER BY id ASC \
603 LIMIT max(0, (SELECT COUNT(*) FROM skill_versions \
604 WHERE skill_name = ? AND source = 'auto') - ?)\
605 )"
606 ))
607 .bind(skill_name)
608 .bind(skill_name)
609 .bind(max_versions)
610 .execute(&self.pool)
611 .await?;
612 Ok(u32::try_from(result.rows_affected()).unwrap_or(0))
613 }
614
615 #[cfg(feature = "postgres")]
621 #[tracing::instrument(skip_all, name = "memory.skills.prune_skill_versions")]
622 pub async fn prune_skill_versions(
623 &self,
624 skill_name: &str,
625 max_versions: u32,
626 ) -> Result<u32, MemoryError> {
627 let result = zeph_db::query(sql!(
628 "DELETE FROM skill_versions WHERE id IN (\
629 SELECT id FROM skill_versions \
630 WHERE skill_name = ? AND source = 'auto' AND is_active = FALSE \
631 ORDER BY id DESC \
632 OFFSET ?\
633 )"
634 ))
635 .bind(skill_name)
636 .bind(i64::from(max_versions))
637 .execute(&self.pool)
638 .await?;
639 Ok(u32::try_from(result.rows_affected()).unwrap_or(0))
640 }
641
642 #[tracing::instrument(skip_all, name = "memory.skills.predecessor_version")]
648 pub async fn predecessor_version(
649 &self,
650 version_id: i64,
651 ) -> Result<Option<SkillVersionRow>, MemoryError> {
652 let pred_id: Option<(Option<i64>,)> = zeph_db::query_as(sql!(
653 "SELECT predecessor_id FROM skill_versions WHERE id = ?"
654 ))
655 .bind(version_id)
656 .fetch_optional(&self.pool)
657 .await?;
658
659 let Some((Some(pid),)) = pred_id else {
660 return Ok(None);
661 };
662
663 let created_at_sel =
665 <zeph_db::ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
666 let raw = format!(
667 "SELECT id, skill_name, version, body, description, source, \
668 is_active, success_count, failure_count, {created_at_sel} \
669 FROM skill_versions WHERE id = ?"
670 );
671 let query_sql = zeph_db::rewrite_placeholders(&raw);
672 let row: Option<SkillVersionTuple> = zeph_db::query_as(sqlx::AssertSqlSafe(query_sql))
673 .bind(pid)
674 .fetch_optional(&self.pool)
675 .await?;
676
677 Ok(row.map(skill_version_from_tuple))
678 }
679
680 #[tracing::instrument(skip_all, name = "memory.skills.list_active_auto_versions")]
687 pub async fn list_active_auto_versions(&self) -> Result<Vec<String>, MemoryError> {
688 let rows: Vec<(String,)> = zeph_db::query_as(sql!(
689 "SELECT skill_name FROM skill_versions WHERE is_active = TRUE AND source = 'auto'"
690 ))
691 .fetch_all(&self.pool)
692 .await?;
693 Ok(rows.into_iter().map(|(name,)| name).collect())
694 }
695
696 #[tracing::instrument(skip_all, name = "memory.skills.insert_tool_usage_log")]
706 pub async fn insert_tool_usage_log(
707 &self,
708 tool_sequence: &str,
709 sequence_hash: &str,
710 context_hash: &str,
711 outcome: &str,
712 conversation_id: Option<crate::types::ConversationId>,
713 ) -> Result<(), MemoryError> {
714 zeph_db::query(sql!(
715 "INSERT INTO skill_usage_log \
716 (tool_sequence, sequence_hash, context_hash, outcome, conversation_id) \
717 VALUES (?, ?, ?, ?, ?)"
718 ))
719 .bind(tool_sequence)
720 .bind(sequence_hash)
721 .bind(context_hash)
722 .bind(outcome)
723 .bind(conversation_id)
724 .execute(&self.pool)
725 .await?;
726 Ok(())
727 }
728
729 #[cfg(feature = "sqlite")]
737 #[tracing::instrument(skip_all, name = "memory.skills.find_recurring_patterns")]
738 pub async fn find_recurring_patterns(
739 &self,
740 min_count: u32,
741 window_days: u32,
742 ) -> Result<Vec<(String, String, u32, u32)>, MemoryError> {
743 let rows: Vec<(String, String, i64, i64)> = zeph_db::query_as(sql!(
744 "SELECT tool_sequence, sequence_hash, \
745 COUNT(*) as occurrence_count, \
746 SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as success_count \
747 FROM skill_usage_log \
748 WHERE created_at > datetime('now', '-' || ? || ' days') \
749 GROUP BY sequence_hash \
750 HAVING occurrence_count >= ? \
751 ORDER BY occurrence_count DESC \
752 LIMIT 10"
753 ))
754 .bind(window_days)
755 .bind(min_count)
756 .fetch_all(&self.pool)
757 .await?;
758
759 Ok(rows
760 .into_iter()
761 .map(|(seq, hash, occ, suc)| {
762 (
763 seq,
764 hash,
765 u32::try_from(occ).unwrap_or(u32::MAX),
766 u32::try_from(suc).unwrap_or(0),
767 )
768 })
769 .collect())
770 }
771
772 #[cfg(feature = "postgres")]
778 #[tracing::instrument(skip_all, name = "memory.skills.find_recurring_patterns")]
779 pub async fn find_recurring_patterns(
780 &self,
781 min_count: u32,
782 window_days: u32,
783 ) -> Result<Vec<(String, String, u32, u32)>, MemoryError> {
784 let rows: Vec<(String, String, i64, i64)> = zeph_db::query_as(sql!(
785 "SELECT tool_sequence, sequence_hash, \
786 COUNT(*) as occurrence_count, \
787 SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as success_count \
788 FROM skill_usage_log \
789 WHERE created_at > NOW() - INTERVAL '1 day' * ? \
790 GROUP BY sequence_hash, tool_sequence \
791 HAVING COUNT(*) >= ? \
792 ORDER BY occurrence_count DESC \
793 LIMIT 10"
794 ))
795 .bind(i64::from(window_days))
796 .bind(i64::from(min_count))
797 .fetch_all(&self.pool)
798 .await?;
799
800 Ok(rows
801 .into_iter()
802 .map(|(seq, hash, occ, suc)| {
803 (
804 seq,
805 hash,
806 u32::try_from(occ).unwrap_or(u32::MAX),
807 u32::try_from(suc).unwrap_or(0),
808 )
809 })
810 .collect())
811 }
812
813 #[cfg(feature = "sqlite")]
818 #[tracing::instrument(skip_all, name = "memory.skills.prune_tool_usage_log")]
819 pub async fn prune_tool_usage_log(&self, retention_days: u32) -> Result<u64, MemoryError> {
820 let result = zeph_db::query(sql!(
821 "DELETE FROM skill_usage_log \
822 WHERE created_at < datetime('now', '-' || ? || ' days')"
823 ))
824 .bind(retention_days)
825 .execute(&self.pool)
826 .await?;
827 Ok(result.rows_affected())
828 }
829
830 #[cfg(feature = "postgres")]
836 #[tracing::instrument(skip_all, name = "memory.skills.prune_tool_usage_log")]
837 pub async fn prune_tool_usage_log(&self, retention_days: u32) -> Result<u64, MemoryError> {
838 let result = zeph_db::query(sql!(
839 "DELETE FROM skill_usage_log \
840 WHERE created_at < NOW() - INTERVAL '1 day' * ?"
841 ))
842 .bind(i64::from(retention_days))
843 .execute(&self.pool)
844 .await?;
845 Ok(result.rows_affected())
846 }
847
848 #[tracing::instrument(skip_all, name = "memory.skills.insert_skill_heuristic")]
855 pub async fn insert_skill_heuristic(
856 &self,
857 skill_name: Option<&str>,
858 heuristic_text: &str,
859 confidence: f64,
860 ) -> Result<i64, MemoryError> {
861 let row: (i64,) = zeph_db::query_as(sql!(
862 "INSERT INTO skill_heuristics (skill_name, heuristic_text, confidence) \
863 VALUES (?, ?, ?) RETURNING id"
864 ))
865 .bind(skill_name)
866 .bind(heuristic_text)
867 .bind(confidence)
868 .fetch_one(&self.pool)
869 .await?;
870 Ok(row.0)
871 }
872
873 #[cfg(feature = "sqlite")]
878 #[tracing::instrument(skip_all, name = "memory.skills.increment_heuristic_use_count")]
879 pub async fn increment_heuristic_use_count(&self, id: i64) -> Result<(), MemoryError> {
880 zeph_db::query(sql!(
881 "UPDATE skill_heuristics \
882 SET use_count = use_count + 1, updated_at = datetime('now') \
883 WHERE id = ?"
884 ))
885 .bind(id)
886 .execute(&self.pool)
887 .await?;
888 Ok(())
889 }
890
891 #[cfg(feature = "postgres")]
896 #[tracing::instrument(skip_all, name = "memory.skills.increment_heuristic_use_count")]
897 pub async fn increment_heuristic_use_count(&self, id: i64) -> Result<(), MemoryError> {
898 zeph_db::query(sql!(
899 "UPDATE skill_heuristics \
900 SET use_count = use_count + 1, updated_at = CURRENT_TIMESTAMP \
901 WHERE id = $1"
902 ))
903 .bind(id)
904 .execute(&self.pool)
905 .await?;
906 Ok(())
907 }
908
909 #[tracing::instrument(skip_all, name = "memory.skills.load_skill_heuristics")]
917 pub async fn load_skill_heuristics(
918 &self,
919 skill_name: &str,
920 min_confidence: f64,
921 limit: u32,
922 ) -> Result<Vec<(i64, String, f64, i64)>, MemoryError> {
923 let rows: Vec<(i64, String, f64, i64)> = zeph_db::query_as(sql!(
924 "SELECT id, heuristic_text, confidence, use_count \
925 FROM skill_heuristics \
926 WHERE (skill_name = ? OR skill_name IS NULL) \
927 AND confidence >= ? \
928 ORDER BY confidence DESC \
929 LIMIT ?"
930 ))
931 .bind(skill_name)
932 .bind(min_confidence)
933 .bind(i64::from(limit))
934 .fetch_all(&self.pool)
935 .await?;
936 Ok(rows)
937 }
938
939 #[tracing::instrument(skip_all, name = "memory.skills.load_all_heuristics_for_skill")]
946 pub async fn load_all_heuristics_for_skill(
947 &self,
948 skill_name: Option<&str>,
949 ) -> Result<Vec<(i64, String)>, MemoryError> {
950 let rows: Vec<(i64, String)> = zeph_db::query_as(sql!(
951 "SELECT id, heuristic_text FROM skill_heuristics \
952 WHERE (skill_name = ? OR (? IS NULL AND skill_name IS NULL))"
953 ))
954 .bind(skill_name)
955 .bind(skill_name)
956 .fetch_all(&self.pool)
957 .await?;
958 Ok(rows)
959 }
960
961 #[cfg(feature = "sqlite")]
972 #[tracing::instrument(skip_all, name = "memory.skills.find_step_corrections")]
973 pub async fn find_step_corrections(
974 &self,
975 skill_name: &str,
976 failure_kind: &str,
977 error_context: &str,
978 tool_name: &str,
979 limit: u32,
980 ) -> Result<Vec<(i64, String)>, MemoryError> {
981 let rows: Vec<(i64, String)> = zeph_db::query_as(sql!(
982 "SELECT id, hint FROM step_corrections \
983 WHERE skill_name = ? \
984 AND (failure_kind = '' OR failure_kind = ?) \
985 AND (error_substring = '' OR INSTR(?, error_substring) > 0) \
986 AND (tool_name = '' OR tool_name = ?) \
987 ORDER BY success_count DESC, use_count DESC \
988 LIMIT ?"
989 ))
990 .bind(skill_name)
991 .bind(failure_kind)
992 .bind(error_context)
993 .bind(tool_name)
994 .bind(limit)
995 .fetch_all(&self.pool)
996 .await?;
997 Ok(rows)
998 }
999
1000 #[cfg(feature = "postgres")]
1006 #[tracing::instrument(skip_all, name = "memory.skills.find_step_corrections")]
1007 pub async fn find_step_corrections(
1008 &self,
1009 skill_name: &str,
1010 failure_kind: &str,
1011 error_context: &str,
1012 tool_name: &str,
1013 limit: u32,
1014 ) -> Result<Vec<(i64, String)>, MemoryError> {
1015 let rows: Vec<(i64, String)> = zeph_db::query_as(sql!(
1016 "SELECT id, hint FROM step_corrections \
1017 WHERE skill_name = ? \
1018 AND (failure_kind = '' OR failure_kind = ?) \
1019 AND (error_substring = '' OR strpos(?, error_substring) > 0) \
1020 AND (tool_name = '' OR tool_name = ?) \
1021 ORDER BY success_count DESC, use_count DESC \
1022 LIMIT ?"
1023 ))
1024 .bind(skill_name)
1025 .bind(failure_kind)
1026 .bind(error_context)
1027 .bind(tool_name)
1028 .bind(i64::from(limit))
1029 .fetch_all(&self.pool)
1030 .await?;
1031 Ok(rows)
1032 }
1033
1034 #[tracing::instrument(skip_all, name = "memory.skills.insert_step_correction")]
1043 pub async fn insert_step_correction(
1044 &self,
1045 skill_name: &str,
1046 failure_kind: &str,
1047 error_substring: &str,
1048 tool_name: &str,
1049 hint: &str,
1050 ) -> Result<(), MemoryError> {
1051 zeph_db::query(sql!(
1052 "INSERT INTO step_corrections \
1053 (skill_name, failure_kind, error_substring, tool_name, hint) \
1054 VALUES (?, ?, ?, ?, ?) \
1055 ON CONFLICT (skill_name, failure_kind, error_substring, tool_name) DO NOTHING"
1056 ))
1057 .bind(skill_name)
1058 .bind(failure_kind)
1059 .bind(error_substring)
1060 .bind(tool_name)
1061 .bind(hint)
1062 .execute(&self.pool)
1063 .await?;
1064 Ok(())
1065 }
1066
1067 #[tracing::instrument(skip_all, name = "memory.skills.record_correction_usage")]
1073 pub async fn record_correction_usage(
1074 &self,
1075 correction_id: i64,
1076 was_successful: bool,
1077 ) -> Result<(), MemoryError> {
1078 if was_successful {
1079 zeph_db::query(sql!(
1080 "UPDATE step_corrections \
1081 SET use_count = use_count + 1, success_count = success_count + 1 \
1082 WHERE id = ?"
1083 ))
1084 .bind(correction_id)
1085 .execute(&self.pool)
1086 .await?;
1087 } else {
1088 zeph_db::query(sql!(
1089 "UPDATE step_corrections SET use_count = use_count + 1 WHERE id = ?"
1090 ))
1091 .bind(correction_id)
1092 .execute(&self.pool)
1093 .await?;
1094 }
1095 Ok(())
1096 }
1097
1098 #[tracing::instrument(skip_all, name = "memory.skills.load_routing_head_weights")]
1108 #[allow(clippy::type_complexity)]
1109 pub async fn load_routing_head_weights(
1110 &self,
1111 ) -> Result<Option<(i64, Vec<u8>, f64, i64)>, MemoryError> {
1112 let row: Option<(i64, Vec<u8>, f64, i64)> = zeph_db::query_as(sql!(
1113 "SELECT embed_dim, weights, baseline, update_count \
1114 FROM routing_head_weights WHERE id = 1"
1115 ))
1116 .fetch_optional(&self.pool)
1117 .await?;
1118 Ok(row)
1119 }
1120
1121 #[cfg(feature = "sqlite")]
1127 #[tracing::instrument(skip_all, name = "memory.skills.save_routing_head_weights")]
1128 pub async fn save_routing_head_weights(
1129 &self,
1130 embed_dim: i64,
1131 weights: &[u8],
1132 baseline: f64,
1133 update_count: i64,
1134 ) -> Result<(), MemoryError> {
1135 zeph_db::query(sql!(
1136 "INSERT INTO routing_head_weights (id, embed_dim, weights, baseline, update_count, updated_at) \
1137 VALUES (1, ?, ?, ?, ?, datetime('now')) \
1138 ON CONFLICT(id) DO UPDATE SET \
1139 embed_dim = excluded.embed_dim, \
1140 weights = excluded.weights, \
1141 baseline = excluded.baseline, \
1142 update_count = excluded.update_count, \
1143 updated_at = datetime('now')"
1144 ))
1145 .bind(embed_dim)
1146 .bind(weights)
1147 .bind(baseline)
1148 .bind(update_count)
1149 .execute(&self.pool)
1150 .await?;
1151 Ok(())
1152 }
1153
1154 #[cfg(feature = "postgres")]
1160 #[tracing::instrument(skip_all, name = "memory.skills.save_routing_head_weights")]
1161 pub async fn save_routing_head_weights(
1162 &self,
1163 embed_dim: i64,
1164 weights: &[u8],
1165 baseline: f64,
1166 update_count: i64,
1167 ) -> Result<(), MemoryError> {
1168 zeph_db::query(sql!(
1169 "INSERT INTO routing_head_weights (id, embed_dim, weights, baseline, update_count, updated_at) \
1170 VALUES (1, $1, $2, $3, $4, CURRENT_TIMESTAMP) \
1171 ON CONFLICT(id) DO UPDATE SET \
1172 embed_dim = excluded.embed_dim, \
1173 weights = excluded.weights, \
1174 baseline = excluded.baseline, \
1175 update_count = excluded.update_count, \
1176 updated_at = CURRENT_TIMESTAMP"
1177 ))
1178 .bind(embed_dim)
1179 .bind(weights)
1180 .bind(baseline)
1181 .bind(update_count)
1182 .execute(&self.pool)
1183 .await?;
1184 Ok(())
1185 }
1186
1187 #[tracing::instrument(skip_all, name = "memory.skills.count_heuristics_by_skill")]
1198 pub async fn count_heuristics_by_skill(
1199 &self,
1200 min_confidence: f64,
1201 min_count: u32,
1202 ) -> Result<Vec<(String, i64)>, MemoryError> {
1203 let rows: Vec<(String, i64)> = zeph_db::query_as(sql!(
1204 "SELECT skill_name, COUNT(*) AS cnt \
1205 FROM skill_heuristics \
1206 WHERE confidence >= ? AND skill_name IS NOT NULL \
1207 GROUP BY skill_name \
1208 HAVING cnt >= ?"
1209 ))
1210 .bind(min_confidence)
1211 .bind(i64::from(min_count))
1212 .fetch_all(&self.pool)
1213 .await?;
1214 Ok(rows)
1215 }
1216
1217 #[tracing::instrument(skip_all, name = "memory.skills.load_heuristic_texts_for_promotion")]
1225 pub async fn load_heuristic_texts_for_promotion(
1226 &self,
1227 skill_name: &str,
1228 min_confidence: f64,
1229 ) -> Result<Vec<String>, MemoryError> {
1230 let rows: Vec<(String,)> = zeph_db::query_as(sql!(
1231 "SELECT heuristic_text \
1232 FROM skill_heuristics \
1233 WHERE skill_name = ? AND confidence >= ? \
1234 ORDER BY heuristic_text ASC"
1235 ))
1236 .bind(skill_name)
1237 .bind(min_confidence)
1238 .fetch_all(&self.pool)
1239 .await?;
1240 Ok(rows.into_iter().map(|r| r.0).collect())
1241 }
1242
1243 #[tracing::instrument(skip_all, name = "memory.skills.promotion_already_evaluated")]
1251 pub async fn promotion_already_evaluated(
1252 &self,
1253 skill_name: &str,
1254 batch_hash: &str,
1255 ) -> Result<bool, MemoryError> {
1256 let count: i64 = zeph_db::query_scalar(sql!(
1257 "SELECT COUNT(*) FROM skill_heuristic_promotions \
1258 WHERE skill_name = ? AND batch_hash = ?"
1259 ))
1260 .bind(skill_name)
1261 .bind(batch_hash)
1262 .fetch_one(&self.pool)
1263 .await?;
1264 Ok(count > 0)
1265 }
1266
1267 #[tracing::instrument(skip_all, name = "memory.skills.record_promotion_evaluation")]
1276 pub async fn record_promotion_evaluation(
1277 &self,
1278 skill_name: &str,
1279 batch_hash: &str,
1280 recommendation: &str,
1281 draft_skill_name: Option<&str>,
1282 ) -> Result<(), MemoryError> {
1283 let now = i64::try_from(
1284 std::time::SystemTime::now()
1285 .duration_since(std::time::UNIX_EPOCH)
1286 .unwrap_or_default()
1287 .as_secs(),
1288 )
1289 .unwrap_or(i64::MAX);
1290 zeph_db::query(sql!(
1291 "INSERT OR IGNORE INTO skill_heuristic_promotions \
1292 (skill_name, batch_hash, evaluated_at, recommendation, draft_skill_name) \
1293 VALUES (?, ?, ?, ?, ?)"
1294 ))
1295 .bind(skill_name)
1296 .bind(batch_hash)
1297 .bind(now)
1298 .bind(recommendation)
1299 .bind(draft_skill_name)
1300 .execute(&self.pool)
1301 .await?;
1302 Ok(())
1303 }
1304}
1305
1306async fn switch_active_skill_version(
1314 tx: &mut zeph_db::DbTransaction<'_>,
1315 skill_name: &str,
1316 version_id: i64,
1317) -> Result<(), MemoryError> {
1318 zeph_db::query(sql!(
1319 "UPDATE skill_versions SET is_active = FALSE WHERE skill_name = ? AND is_active = TRUE"
1320 ))
1321 .bind(skill_name)
1322 .execute(&mut **tx)
1323 .await?;
1324
1325 zeph_db::query(sql!(
1326 "UPDATE skill_versions SET is_active = TRUE WHERE id = ?"
1327 ))
1328 .bind(version_id)
1329 .execute(&mut **tx)
1330 .await?;
1331 Ok(())
1332}
1333
1334#[cfg(test)]
1335mod tests {
1336 use std::time::Duration;
1337
1338 use tokio::time::sleep;
1339
1340 use super::*;
1341
1342 async fn test_store() -> SqliteStore {
1343 SqliteStore::new(":memory:").await.unwrap()
1344 }
1345
1346 #[tokio::test]
1347 async fn record_skill_usage_increments() {
1348 let store = test_store().await;
1349
1350 store.record_skill_usage(&["git"]).await.unwrap();
1351 store.record_skill_usage(&["git"]).await.unwrap();
1352
1353 let usage = store.load_skill_usage().await.unwrap();
1354 assert_eq!(usage.len(), 1);
1355 assert_eq!(usage[0].skill_name, "git");
1356 assert_eq!(usage[0].invocation_count, 2);
1357 }
1358
1359 #[tokio::test]
1360 async fn load_skill_usage_returns_all() {
1361 let store = test_store().await;
1362
1363 store.record_skill_usage(&["git", "docker"]).await.unwrap();
1364 store.record_skill_usage(&["git"]).await.unwrap();
1365
1366 let usage = store.load_skill_usage().await.unwrap();
1367 assert_eq!(usage.len(), 2);
1368 assert_eq!(usage[0].skill_name, "git");
1369 assert_eq!(usage[0].invocation_count, 2);
1370 assert_eq!(usage[1].skill_name, "docker");
1371 assert_eq!(usage[1].invocation_count, 1);
1372 }
1373
1374 #[tokio::test]
1375 async fn migration_005_creates_tables() {
1376 let store = test_store().await;
1377 let pool = store.pool();
1378
1379 let versions: (i64,) = zeph_db::query_as(sql!(
1380 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='skill_versions'"
1381 ))
1382 .fetch_one(pool)
1383 .await
1384 .unwrap();
1385 assert_eq!(versions.0, 1);
1386
1387 let outcomes: (i64,) = zeph_db::query_as(sql!(
1388 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='skill_outcomes'"
1389 ))
1390 .fetch_one(pool)
1391 .await
1392 .unwrap();
1393 assert_eq!(outcomes.0, 1);
1394 }
1395
1396 #[tokio::test]
1397 async fn record_skill_outcome_inserts() {
1398 let store = test_store().await;
1399
1400 store
1401 .record_skill_outcome(
1402 "git",
1403 None,
1404 Some(crate::types::ConversationId(1)),
1405 "success",
1406 None,
1407 None,
1408 )
1409 .await
1410 .unwrap();
1411 store
1412 .record_skill_outcome(
1413 "git",
1414 None,
1415 Some(crate::types::ConversationId(1)),
1416 "tool_failure",
1417 Some("exit code 1"),
1418 None,
1419 )
1420 .await
1421 .unwrap();
1422
1423 let metrics = store.skill_metrics("git").await.unwrap().unwrap();
1424 assert_eq!(metrics.total, 2);
1425 assert_eq!(metrics.successes, 1);
1426 assert_eq!(metrics.failures, 1);
1427 }
1428
1429 #[tokio::test]
1430 async fn skill_metrics_none_for_unknown() {
1431 let store = test_store().await;
1432 let m = store.skill_metrics("nonexistent").await.unwrap();
1433 assert!(m.is_none());
1434 }
1435
1436 #[tokio::test]
1437 async fn load_skill_outcome_stats_grouped() {
1438 let store = test_store().await;
1439
1440 store
1441 .record_skill_outcome("git", None, None, "success", None, None)
1442 .await
1443 .unwrap();
1444 store
1445 .record_skill_outcome("git", None, None, "tool_failure", None, None)
1446 .await
1447 .unwrap();
1448 store
1449 .record_skill_outcome("docker", None, None, "success", None, None)
1450 .await
1451 .unwrap();
1452
1453 let stats = store.load_skill_outcome_stats().await.unwrap();
1454 assert_eq!(stats.len(), 2);
1455 assert_eq!(stats[0].skill_name, "git");
1456 assert_eq!(stats[0].total, 2);
1457 assert_eq!(stats[1].skill_name, "docker");
1458 assert_eq!(stats[1].total, 1);
1459 }
1460
1461 #[tokio::test]
1462 async fn save_and_load_skill_version() {
1463 let store = test_store().await;
1464
1465 let id = store
1466 .save_skill_version("git", 1, "body v1", "Git helper", "manual", None, None)
1467 .await
1468 .unwrap();
1469 assert!(id > 0);
1470
1471 store.activate_skill_version("git", id).await.unwrap();
1472
1473 let active = store.active_skill_version("git").await.unwrap().unwrap();
1474 assert_eq!(active.version, 1);
1475 assert_eq!(active.body, "body v1");
1476 assert!(active.is_active);
1477 }
1478
1479 #[tokio::test]
1480 async fn activate_deactivates_previous() {
1481 let store = test_store().await;
1482
1483 let v1 = store
1484 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
1485 .await
1486 .unwrap();
1487 store.activate_skill_version("git", v1).await.unwrap();
1488
1489 let v2 = store
1490 .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
1491 .await
1492 .unwrap();
1493 store.activate_skill_version("git", v2).await.unwrap();
1494
1495 let versions = store.load_skill_versions("git").await.unwrap();
1496 assert_eq!(versions.len(), 2);
1497 assert!(!versions[0].is_active);
1498 assert!(versions[1].is_active);
1499 }
1500
1501 #[tokio::test]
1502 async fn next_skill_version_increments() {
1503 let store = test_store().await;
1504
1505 let next = store.next_skill_version("git").await.unwrap();
1506 assert_eq!(next, 1);
1507
1508 store
1509 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
1510 .await
1511 .unwrap();
1512 let next = store.next_skill_version("git").await.unwrap();
1513 assert_eq!(next, 2);
1514 }
1515
1516 #[tokio::test]
1517 async fn last_improvement_time_returns_auto_only() {
1518 let store = test_store().await;
1519
1520 store
1521 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
1522 .await
1523 .unwrap();
1524
1525 let t = store.last_improvement_time("git").await.unwrap();
1526 assert!(t.is_none());
1527
1528 store
1529 .save_skill_version("git", 2, "v2", "desc", "auto", None, None)
1530 .await
1531 .unwrap();
1532
1533 let t = store.last_improvement_time("git").await.unwrap();
1534 assert!(t.is_some());
1535 }
1536
1537 #[tokio::test]
1538 async fn ensure_skill_version_exists_idempotent() {
1539 let store = test_store().await;
1540
1541 store
1542 .ensure_skill_version_exists("git", "body", "Git helper")
1543 .await
1544 .unwrap();
1545 store
1546 .ensure_skill_version_exists("git", "body2", "Git helper 2")
1547 .await
1548 .unwrap();
1549
1550 let versions = store.load_skill_versions("git").await.unwrap();
1551 assert_eq!(versions.len(), 1);
1552 assert_eq!(versions[0].body, "body");
1553 }
1554
1555 #[tokio::test]
1556 async fn load_skill_versions_ordered() {
1557 let store = test_store().await;
1558
1559 let v1 = store
1560 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
1561 .await
1562 .unwrap();
1563 store
1564 .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
1565 .await
1566 .unwrap();
1567
1568 let versions = store.load_skill_versions("git").await.unwrap();
1569 assert_eq!(versions.len(), 2);
1570 assert_eq!(versions[0].version, 1);
1571 assert_eq!(versions[1].version, 2);
1572 }
1573
1574 #[tokio::test]
1575 async fn count_auto_versions_only() {
1576 let store = test_store().await;
1577
1578 store
1579 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
1580 .await
1581 .unwrap();
1582 store
1583 .save_skill_version("git", 2, "v2", "desc", "auto", None, None)
1584 .await
1585 .unwrap();
1586 store
1587 .save_skill_version("git", 3, "v3", "desc", "auto", None, None)
1588 .await
1589 .unwrap();
1590
1591 let count = store.count_auto_versions("git").await.unwrap();
1592 assert_eq!(count, 2);
1593 }
1594
1595 #[tokio::test]
1596 async fn prune_preserves_manual_and_active() {
1597 let store = test_store().await;
1598
1599 let v1 = store
1600 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
1601 .await
1602 .unwrap();
1603 store.activate_skill_version("git", v1).await.unwrap();
1604
1605 for i in 2..=5 {
1606 store
1607 .save_skill_version("git", i, &format!("v{i}"), "desc", "auto", None, None)
1608 .await
1609 .unwrap();
1610 }
1611
1612 let pruned = store.prune_skill_versions("git", 2).await.unwrap();
1613 assert_eq!(pruned, 2);
1614
1615 let versions = store.load_skill_versions("git").await.unwrap();
1616 assert!(versions.iter().any(|v| v.source == "manual"));
1617 let auto_count = versions.iter().filter(|v| v.source == "auto").count();
1618 assert_eq!(auto_count, 2);
1619 }
1620
1621 #[tokio::test]
1622 async fn predecessor_version_returns_parent() {
1623 let store = test_store().await;
1624
1625 let v1 = store
1626 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
1627 .await
1628 .unwrap();
1629 let v2 = store
1630 .save_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
1631 .await
1632 .unwrap();
1633
1634 let pred = store.predecessor_version(v2).await.unwrap().unwrap();
1635 assert_eq!(pred.id, v1);
1636 assert_eq!(pred.version, 1);
1637 }
1638
1639 #[tokio::test]
1640 async fn predecessor_version_none_for_root() {
1641 let store = test_store().await;
1642
1643 let v1 = store
1644 .save_skill_version("git", 1, "v1", "desc", "manual", None, None)
1645 .await
1646 .unwrap();
1647
1648 let pred = store.predecessor_version(v1).await.unwrap();
1649 assert!(pred.is_none());
1650 }
1651
1652 #[tokio::test]
1653 async fn active_skill_version_none_for_unknown() {
1654 let store = test_store().await;
1655 let active = store.active_skill_version("nonexistent").await.unwrap();
1656 assert!(active.is_none());
1657 }
1658
1659 #[tokio::test]
1660 async fn load_skill_outcome_stats_empty() {
1661 let store = test_store().await;
1662 let stats = store.load_skill_outcome_stats().await.unwrap();
1663 assert!(stats.is_empty());
1664 }
1665
1666 #[tokio::test]
1667 async fn load_skill_versions_empty() {
1668 let store = test_store().await;
1669 let versions = store.load_skill_versions("nonexistent").await.unwrap();
1670 assert!(versions.is_empty());
1671 }
1672
1673 #[tokio::test]
1674 async fn count_auto_versions_zero_for_unknown() {
1675 let store = test_store().await;
1676 let count = store.count_auto_versions("nonexistent").await.unwrap();
1677 assert_eq!(count, 0);
1678 }
1679
1680 #[tokio::test]
1681 async fn prune_nothing_when_below_limit() {
1682 let store = test_store().await;
1683
1684 store
1685 .save_skill_version("git", 1, "v1", "desc", "auto", None, None)
1686 .await
1687 .unwrap();
1688
1689 let pruned = store.prune_skill_versions("git", 5).await.unwrap();
1690 assert_eq!(pruned, 0);
1691 }
1692
1693 #[tokio::test]
1694 async fn record_skill_outcome_with_error_context() {
1695 let store = test_store().await;
1696
1697 store
1698 .record_skill_outcome(
1699 "docker",
1700 None,
1701 Some(crate::types::ConversationId(1)),
1702 "tool_failure",
1703 Some("container not found"),
1704 None,
1705 )
1706 .await
1707 .unwrap();
1708
1709 let metrics = store.skill_metrics("docker").await.unwrap().unwrap();
1710 assert_eq!(metrics.total, 1);
1711 assert_eq!(metrics.failures, 1);
1712 }
1713
1714 #[tokio::test]
1715 async fn save_skill_version_with_error_context() {
1716 let store = test_store().await;
1717
1718 let id = store
1719 .save_skill_version(
1720 "git",
1721 1,
1722 "improved body",
1723 "Git helper",
1724 "auto",
1725 Some("exit code 128"),
1726 None,
1727 )
1728 .await
1729 .unwrap();
1730 assert!(id > 0);
1731 }
1732
1733 #[tokio::test]
1734 async fn record_skill_outcomes_batch_resolves_version_id() {
1735 let store = test_store().await;
1736
1737 let vid = store
1738 .save_skill_version("git", 1, "body", "desc", "manual", None, None)
1739 .await
1740 .unwrap();
1741 store.activate_skill_version("git", vid).await.unwrap();
1742
1743 store
1744 .record_skill_outcomes_batch(
1745 &["git".to_string()],
1746 None,
1747 "tool_failure",
1748 Some("exit code 1"),
1749 Some("exit_nonzero"),
1750 )
1751 .await
1752 .unwrap();
1753
1754 let pool = store.pool();
1755 let row: (Option<i64>, Option<String>) = zeph_db::query_as(sql!(
1756 "SELECT version_id, outcome_detail FROM skill_outcomes WHERE skill_name = 'git' LIMIT 1"
1757 ))
1758 .fetch_one(pool)
1759 .await
1760 .unwrap();
1761 assert_eq!(
1762 row.0,
1763 Some(vid),
1764 "version_id should be resolved to active version"
1765 );
1766 assert_eq!(row.1.as_deref(), Some("exit_nonzero"));
1767 }
1768
1769 #[tokio::test]
1770 async fn record_skill_outcome_stores_outcome_detail() {
1771 let store = test_store().await;
1772
1773 store
1774 .record_skill_outcome("docker", None, None, "tool_failure", None, Some("timeout"))
1775 .await
1776 .unwrap();
1777
1778 let pool = store.pool();
1779 let row: (Option<String>,) = zeph_db::query_as(sql!(
1780 "SELECT outcome_detail FROM skill_outcomes WHERE skill_name = 'docker' LIMIT 1"
1781 ))
1782 .fetch_one(pool)
1783 .await
1784 .unwrap();
1785 assert_eq!(row.0.as_deref(), Some("timeout"));
1786 }
1787
1788 #[tokio::test]
1789 async fn record_skill_outcomes_batch_waits_for_active_writer() {
1790 let file = tempfile::NamedTempFile::new().expect("tempfile");
1791 let path = file.path().to_str().expect("valid path").to_owned();
1792 let store = SqliteStore::with_pool_size(&path, 2)
1793 .await
1794 .expect("with_pool_size");
1795
1796 let vid = store
1797 .save_skill_version("git", 1, "body", "desc", "manual", None, None)
1798 .await
1799 .unwrap();
1800 store.activate_skill_version("git", vid).await.unwrap();
1801
1802 let mut writer_tx = begin_write(store.pool()).await.expect("begin immediate");
1803 zeph_db::query(sql!("INSERT INTO conversations DEFAULT VALUES"))
1804 .execute(&mut *writer_tx)
1805 .await
1806 .expect("hold write lock");
1807
1808 let batch_store = store.clone();
1809 let batch_fut = async move {
1810 batch_store
1811 .record_skill_outcomes_batch(
1812 &["git".to_string()],
1813 None,
1814 "success",
1815 None,
1816 Some("waited_for_writer"),
1817 )
1818 .await
1819 };
1820 let batch = tokio::spawn(batch_fut); sleep(Duration::from_millis(100)).await;
1823 writer_tx.commit().await.expect("commit writer");
1824
1825 batch
1826 .await
1827 .expect("join batch task")
1828 .expect("record outcomes");
1829
1830 let count: i64 = zeph_db::query_scalar(sql!(
1831 "SELECT COUNT(*) FROM skill_outcomes WHERE skill_name = 'git'"
1832 ))
1833 .fetch_one(store.pool())
1834 .await
1835 .unwrap();
1836 assert_eq!(
1837 count, 1,
1838 "expected batch insert to succeed after writer commits"
1839 );
1840 }
1841
1842 #[tokio::test]
1843 async fn distinct_session_count_empty() {
1844 let store = test_store().await;
1845 let count = store.distinct_session_count("unknown-skill").await.unwrap();
1846 assert_eq!(count, 0);
1847 }
1848
1849 #[tokio::test]
1850 async fn distinct_session_count_single_session() {
1851 let store = test_store().await;
1852 let cid = crate::types::ConversationId(1);
1853 store
1854 .record_skill_outcome("git", None, Some(cid), "success", None, None)
1855 .await
1856 .unwrap();
1857 store
1858 .record_skill_outcome("git", None, Some(cid), "tool_failure", None, None)
1859 .await
1860 .unwrap();
1861 let count = store.distinct_session_count("git").await.unwrap();
1862 assert_eq!(count, 1);
1863 }
1864
1865 #[tokio::test]
1866 async fn distinct_session_count_multiple_sessions() {
1867 let store = test_store().await;
1868 for i in 0..3i64 {
1869 store
1870 .record_skill_outcome(
1871 "git",
1872 None,
1873 Some(crate::types::ConversationId(i)),
1874 "success",
1875 None,
1876 None,
1877 )
1878 .await
1879 .unwrap();
1880 }
1881 let count = store.distinct_session_count("git").await.unwrap();
1882 assert_eq!(count, 3);
1883 }
1884
1885 #[tokio::test]
1886 async fn distinct_session_count_null_conversation_ids_excluded() {
1887 let store = test_store().await;
1888 store
1890 .record_skill_outcome("git", None, None, "success", None, None)
1891 .await
1892 .unwrap();
1893 store
1894 .record_skill_outcome("git", None, None, "success", None, None)
1895 .await
1896 .unwrap();
1897 let count = store.distinct_session_count("git").await.unwrap();
1898 assert_eq!(count, 0, "NULL conversation_ids must not be counted");
1899 }
1900
1901 #[tokio::test]
1904 async fn insert_and_find_recurring_patterns() {
1905 let store = test_store().await;
1906 let seq = r#"["shell","web_scrape"]"#;
1907 let hash = "abcdef0123456789";
1908 let ctx = "ctxhash000000000";
1909
1910 for _ in 0..3 {
1911 store
1912 .insert_tool_usage_log(seq, hash, ctx, "success", None)
1913 .await
1914 .unwrap();
1915 }
1916 store
1917 .insert_tool_usage_log(seq, hash, ctx, "failure", None)
1918 .await
1919 .unwrap();
1920
1921 let patterns = store.find_recurring_patterns(3, 90).await.unwrap();
1922 assert_eq!(patterns.len(), 1);
1923 let (s, h, occ, suc) = &patterns[0];
1924 assert_eq!(s, seq);
1925 assert_eq!(h, hash);
1926 assert_eq!(*occ, 4);
1927 assert_eq!(*suc, 3);
1928 }
1929
1930 #[tokio::test]
1931 async fn find_recurring_patterns_below_threshold_returns_empty() {
1932 let store = test_store().await;
1933 let seq = r#"["shell"]"#;
1934 let hash = "0000000000000001";
1935 let ctx = "0000000000000001";
1936
1937 store
1938 .insert_tool_usage_log(seq, hash, ctx, "success", None)
1939 .await
1940 .unwrap();
1941
1942 let patterns = store.find_recurring_patterns(3, 90).await.unwrap();
1943 assert!(patterns.is_empty());
1944 }
1945
1946 #[tokio::test]
1947 async fn prune_tool_usage_log_removes_old_rows() {
1948 let store = test_store().await;
1949 zeph_db::query(sql!(
1951 "INSERT INTO skill_usage_log \
1952 (tool_sequence, sequence_hash, context_hash, outcome, created_at) \
1953 VALUES (?, ?, ?, ?, datetime('now', '-2 days'))"
1954 ))
1955 .bind(r#"["shell"]"#)
1956 .bind("hash0000000000001")
1957 .bind("ctx00000000000001")
1958 .bind("success")
1959 .execute(store.pool())
1960 .await
1961 .unwrap();
1962
1963 let removed = store.prune_tool_usage_log(1).await.unwrap();
1965 assert_eq!(removed, 1);
1966 }
1967
1968 #[tokio::test]
1971 async fn insert_and_load_skill_heuristics() {
1972 let store = test_store().await;
1973
1974 let id = store
1975 .insert_skill_heuristic(Some("git"), "always commit in small chunks", 0.9)
1976 .await
1977 .unwrap();
1978 assert!(id > 0);
1979
1980 let rows = store.load_skill_heuristics("git", 0.5, 10).await.unwrap();
1981 assert_eq!(rows.len(), 1);
1982 assert_eq!(rows[0].1, "always commit in small chunks");
1983 assert!((rows[0].2 - 0.9).abs() < 1e-6);
1984 }
1985
1986 #[tokio::test]
1987 async fn load_skill_heuristics_includes_general() {
1988 let store = test_store().await;
1989
1990 store
1991 .insert_skill_heuristic(None, "general tip", 0.7)
1992 .await
1993 .unwrap();
1994 store
1995 .insert_skill_heuristic(Some("git"), "git tip", 0.8)
1996 .await
1997 .unwrap();
1998
1999 let rows = store.load_skill_heuristics("git", 0.5, 10).await.unwrap();
2001 assert_eq!(rows.len(), 2);
2002 }
2003
2004 #[tokio::test]
2005 async fn load_skill_heuristics_filters_by_min_confidence() {
2006 let store = test_store().await;
2007
2008 store
2009 .insert_skill_heuristic(Some("git"), "low confidence tip", 0.3)
2010 .await
2011 .unwrap();
2012 store
2013 .insert_skill_heuristic(Some("git"), "high confidence tip", 0.9)
2014 .await
2015 .unwrap();
2016
2017 let rows = store.load_skill_heuristics("git", 0.5, 10).await.unwrap();
2018 assert_eq!(rows.len(), 1);
2019 assert_eq!(rows[0].1, "high confidence tip");
2020 }
2021
2022 #[tokio::test]
2023 async fn increment_heuristic_use_count_works() {
2024 let store = test_store().await;
2025
2026 let id = store
2027 .insert_skill_heuristic(Some("git"), "tip", 0.8)
2028 .await
2029 .unwrap();
2030
2031 store.increment_heuristic_use_count(id).await.unwrap();
2032 store.increment_heuristic_use_count(id).await.unwrap();
2033
2034 let rows = store.load_skill_heuristics("git", 0.0, 10).await.unwrap();
2035 assert_eq!(rows[0].3, 2); }
2037
2038 #[tokio::test]
2039 async fn load_all_heuristics_for_skill_exact_match() {
2040 let store = test_store().await;
2041
2042 store
2043 .insert_skill_heuristic(Some("git"), "git tip", 0.8)
2044 .await
2045 .unwrap();
2046 store
2047 .insert_skill_heuristic(Some("docker"), "docker tip", 0.8)
2048 .await
2049 .unwrap();
2050
2051 let rows = store
2052 .load_all_heuristics_for_skill(Some("git"))
2053 .await
2054 .unwrap();
2055 assert_eq!(rows.len(), 1);
2056 assert_eq!(rows[0].1, "git tip");
2057 }
2058
2059 #[tokio::test]
2060 async fn load_all_heuristics_for_skill_null() {
2061 let store = test_store().await;
2062
2063 store
2064 .insert_skill_heuristic(None, "general", 0.8)
2065 .await
2066 .unwrap();
2067 store
2068 .insert_skill_heuristic(Some("git"), "git tip", 0.8)
2069 .await
2070 .unwrap();
2071
2072 let rows = store.load_all_heuristics_for_skill(None).await.unwrap();
2073 assert_eq!(rows.len(), 1);
2074 assert_eq!(rows[0].1, "general");
2075 }
2076
2077 #[tokio::test]
2078 async fn skill_trust_default_is_quarantined() {
2079 let store = test_store().await;
2083
2084 zeph_db::query(sql!(
2086 "INSERT INTO skill_trust (skill_name, blake3_hash) VALUES ('test-arise', 'abc')"
2087 ))
2088 .execute(store.pool())
2089 .await
2090 .unwrap();
2091
2092 let trust: (String,) = zeph_db::query_as(sql!(
2093 "SELECT trust_level FROM skill_trust WHERE skill_name = 'test-arise'"
2094 ))
2095 .fetch_one(store.pool())
2096 .await
2097 .unwrap();
2098
2099 assert_eq!(
2100 trust.0, "quarantined",
2101 "schema default for skill_trust.trust_level must be 'quarantined'"
2102 );
2103 }
2104
2105 #[tokio::test]
2106 async fn save_and_activate_skill_version_activates_new() {
2107 let store = test_store().await;
2108
2109 let id = store
2110 .save_and_activate_skill_version(
2111 "git",
2112 1,
2113 "body v1",
2114 "Git helper",
2115 "manual",
2116 None,
2117 None,
2118 )
2119 .await
2120 .unwrap();
2121 assert!(id > 0);
2122
2123 let active = store.active_skill_version("git").await.unwrap().unwrap();
2124 assert_eq!(active.id, id);
2125 assert_eq!(active.version, 1);
2126 assert_eq!(active.body, "body v1");
2127 assert!(active.is_active);
2128 }
2129
2130 #[tokio::test]
2131 async fn save_and_activate_skill_version_deactivates_previous() {
2132 let store = test_store().await;
2133
2134 let v1 = store
2135 .save_and_activate_skill_version("git", 1, "v1", "desc", "manual", None, None)
2136 .await
2137 .unwrap();
2138
2139 let v2 = store
2140 .save_and_activate_skill_version("git", 2, "v2", "desc", "auto", None, Some(v1))
2141 .await
2142 .unwrap();
2143
2144 let versions = store.load_skill_versions("git").await.unwrap();
2145 assert_eq!(versions.len(), 2);
2146
2147 let old = versions.iter().find(|v| v.id == v1).unwrap();
2148 let new = versions.iter().find(|v| v.id == v2).unwrap();
2149 assert!(!old.is_active, "previous version must be deactivated");
2150 assert!(new.is_active, "new version must be active");
2151 }
2152
2153 #[tokio::test]
2154 async fn save_and_activate_skill_version_only_one_active() {
2155 let store = test_store().await;
2156
2157 let mut last_id = 0i64;
2158 for i in 1i64..=4 {
2159 last_id = store
2160 .save_and_activate_skill_version(
2161 "git",
2162 i,
2163 &format!("v{i}"),
2164 "desc",
2165 "auto",
2166 None,
2167 None,
2168 )
2169 .await
2170 .unwrap();
2171 }
2172
2173 let versions = store.load_skill_versions("git").await.unwrap();
2174 let active_count = versions.iter().filter(|v| v.is_active).count();
2175 assert_eq!(active_count, 1, "exactly one version must be active");
2176 assert_eq!(
2177 versions.iter().find(|v| v.is_active).unwrap().id,
2178 last_id,
2179 "the last saved version must be the active one"
2180 );
2181 }
2182
2183 async fn insert_heuristic(store: &SqliteStore, skill: &str, text: &str, confidence: f64) {
2186 zeph_db::query(sql!(
2187 "INSERT INTO skill_heuristics (skill_name, heuristic_text, confidence) VALUES (?, ?, ?)"
2188 ))
2189 .bind(skill)
2190 .bind(text)
2191 .bind(confidence)
2192 .execute(store.pool())
2193 .await
2194 .unwrap();
2195 }
2196
2197 #[tokio::test]
2200 async fn count_heuristics_by_skill_excludes_null_skill_name() {
2201 let store = test_store().await;
2202
2203 store
2205 .insert_skill_heuristic(None, "general tip 1", 0.9)
2206 .await
2207 .unwrap();
2208 store
2209 .insert_skill_heuristic(None, "general tip 2", 0.8)
2210 .await
2211 .unwrap();
2212 store
2213 .insert_skill_heuristic(None, "general tip 3", 0.7)
2214 .await
2215 .unwrap();
2216
2217 let results = store.count_heuristics_by_skill(0.5, 2).await.unwrap();
2219 assert!(
2220 results.is_empty(),
2221 "NULL skill_name heuristics must be excluded from count_heuristics_by_skill results"
2222 );
2223
2224 insert_heuristic(&store, "git", "use --no-pager", 0.8).await;
2226 insert_heuristic(&store, "git", "check status first", 0.9).await;
2227
2228 let results = store.count_heuristics_by_skill(0.5, 2).await.unwrap();
2229 assert_eq!(results.len(), 1, "only git should qualify");
2230 assert_eq!(results[0].0, "git");
2231 assert_eq!(results[0].1, 2, "git has exactly 2 qualifying heuristics");
2232 }
2233
2234 #[tokio::test]
2235 async fn count_heuristics_by_skill_threshold_and_confidence() {
2236 let store = test_store().await;
2237
2238 insert_heuristic(&store, "git", "use --no-pager", 0.8).await;
2240 insert_heuristic(&store, "git", "check status first", 0.9).await;
2241 insert_heuristic(&store, "git", "low confidence hint", 0.3).await;
2242
2243 insert_heuristic(&store, "docker", "use --rm", 0.7).await;
2245
2246 let results = store.count_heuristics_by_skill(0.5, 2).await.unwrap();
2247 assert_eq!(results.len(), 1, "only git meets threshold");
2248 assert_eq!(results[0].0, "git");
2249 assert_eq!(results[0].1, 2, "only heuristics >= 0.5 confidence counted");
2250 }
2251
2252 #[tokio::test]
2253 async fn load_heuristic_texts_sorted_for_deterministic_hash() {
2254 let store = test_store().await;
2255
2256 insert_heuristic(&store, "git", "zebra", 0.8).await;
2257 insert_heuristic(&store, "git", "alpha", 0.9).await;
2258 insert_heuristic(&store, "git", "middle", 0.7).await;
2259 insert_heuristic(&store, "git", "skipped", 0.2).await; let texts = store
2262 .load_heuristic_texts_for_promotion("git", 0.5)
2263 .await
2264 .unwrap();
2265
2266 assert_eq!(texts.len(), 3);
2267 assert_eq!(
2268 texts,
2269 vec!["alpha", "middle", "zebra"],
2270 "must be sorted ASC"
2271 );
2272 }
2273
2274 #[tokio::test]
2275 async fn promotion_already_evaluated_idempotency() {
2276 let store = test_store().await;
2277
2278 let found = store
2280 .promotion_already_evaluated("git", "abc123")
2281 .await
2282 .unwrap();
2283 assert!(!found);
2284
2285 store
2286 .record_promotion_evaluation("git", "abc123", "none", None)
2287 .await
2288 .unwrap();
2289
2290 let found = store
2292 .promotion_already_evaluated("git", "abc123")
2293 .await
2294 .unwrap();
2295 assert!(found);
2296 }
2297
2298 #[tokio::test]
2299 async fn record_promotion_evaluation_insert_or_ignore() {
2300 let store = test_store().await;
2301
2302 store
2304 .record_promotion_evaluation("git", "hash1", "body_enrichment", Some("git-v2"))
2305 .await
2306 .unwrap();
2307
2308 store
2310 .record_promotion_evaluation("git", "hash1", "new_skill", Some("git-extra"))
2311 .await
2312 .unwrap();
2313
2314 let rec: (String,) = zeph_db::query_as(sql!(
2316 "SELECT recommendation FROM skill_heuristic_promotions WHERE skill_name = ? AND batch_hash = ?"
2317 ))
2318 .bind("git")
2319 .bind("hash1")
2320 .fetch_one(store.pool())
2321 .await
2322 .unwrap();
2323 assert_eq!(
2324 rec.0, "body_enrichment",
2325 "first insert wins (INSERT OR IGNORE)"
2326 );
2327 }
2328}