Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit efb1edf

Browse files
z23ccclaude
andcommitted
feat(flowctl-db): add skills table and skill module for semantic routing
Add skills table (name, description, embedding) with vector index for semantic skill matching. SkillRepo provides upsert() and match_skills() using cosine similarity via BGE-small embeddings. Reuses embed_one()/ensure_embedder() from memory module — zero duplication. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5799cca commit efb1edf

5 files changed

Lines changed: 289 additions & 4 deletions

File tree

flowctl/crates/flowctl-db/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,14 @@ pub mod memory;
2424
pub mod metrics;
2525
pub mod pool;
2626
pub mod repo;
27+
pub mod skill;
2728

2829
pub use error::DbError;
2930
pub use indexer::{reindex, ReindexResult};
3031
pub use events::{EventLog, TaskTokenSummary, TokenRecord, TokenUsageRow};
3132
pub use memory::{MemoryEntry, MemoryFilter, MemoryRepo};
3233
pub use metrics::StatsQuery;
34+
pub use skill::{SkillEntry, SkillMatch, SkillRepo};
3335
pub use pool::{cleanup, open_async, open_memory_async, resolve_db_path, resolve_libsql_path, resolve_state_dir};
3436
pub use repo::{
3537
DepRepo, EpicRepo, EventRepo, EventRow, EvidenceRepo, FileLockRepo, FileOwnershipRepo,

flowctl/crates/flowctl-db/src/memory.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ static EMBEDDER: OnceCell<Result<Mutex<TextEmbedding>, String>> = OnceCell::cons
8888
/// model (~130MB) via fastembed; subsequent calls return the cached
8989
/// instance. Initialization runs on a blocking thread because fastembed
9090
/// performs synchronous file I/O.
91-
async fn ensure_embedder() -> Result<(), DbError> {
91+
pub(crate) async fn ensure_embedder() -> Result<(), DbError> {
9292
let res = EMBEDDER
9393
.get_or_init(|| async {
9494
match tokio::task::spawn_blocking(|| {
@@ -109,7 +109,7 @@ async fn ensure_embedder() -> Result<(), DbError> {
109109
}
110110

111111
/// Embed a single passage into a 384-dim vector.
112-
async fn embed_one(text: &str) -> Result<Vec<f32>, DbError> {
112+
pub(crate) async fn embed_one(text: &str) -> Result<Vec<f32>, DbError> {
113113
ensure_embedder().await?;
114114
let text = text.to_string();
115115
let result = tokio::task::spawn_blocking(move || {
@@ -132,7 +132,7 @@ async fn embed_one(text: &str) -> Result<Vec<f32>, DbError> {
132132
}
133133

134134
/// Convert a `Vec<f32>` into a libSQL `vector32()` literal string.
135-
fn vec_to_literal(v: &[f32]) -> String {
135+
pub(crate) fn vec_to_literal(v: &[f32]) -> String {
136136
let parts: Vec<String> = v.iter().map(std::string::ToString::to_string).collect();
137137
format!("[{}]", parts.join(","))
138138
}

flowctl/crates/flowctl-db/src/pool.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ async fn apply_schema(conn: &Connection) -> Result<(), DbError> {
9898
.await
9999
.map_err(|e| DbError::Schema(format!("reverse deps backfill failed: {e}")))?;
100100

101-
// Try to create the vector index (requires libSQL server extensions).
101+
// Try to create vector indexes (requires libSQL server extensions).
102102
// Gracefully degrade if not available (embedded/core mode).
103103
let _ = conn
104104
.execute(
@@ -107,6 +107,13 @@ async fn apply_schema(conn: &Connection) -> Result<(), DbError> {
107107
)
108108
.await;
109109

110+
let _ = conn
111+
.execute(
112+
"CREATE INDEX IF NOT EXISTS skills_emb_idx ON skills(libsql_vector_idx(embedding))",
113+
(),
114+
)
115+
.await;
116+
110117
Ok(())
111118
}
112119

@@ -215,6 +222,7 @@ mod tests {
215222
"daily_rollup",
216223
"monthly_rollup",
217224
"memory",
225+
"skills",
218226
] {
219227
assert!(
220228
tables.contains(&expected.to_string()),

flowctl/crates/flowctl-db/src/schema.sql

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,21 @@ CREATE TABLE IF NOT EXISTS memory (
217217
embedding BLOB
218218
);
219219

220+
-- ── Skills with native vector embedding (BGE-small, 384-dim) ───────
221+
222+
CREATE TABLE IF NOT EXISTS skills (
223+
id INTEGER PRIMARY KEY AUTOINCREMENT,
224+
name TEXT NOT NULL UNIQUE,
225+
description TEXT NOT NULL,
226+
plugin_path TEXT,
227+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
228+
embedding BLOB
229+
);
230+
231+
-- Native libSQL vector index for semantic skill matching
232+
-- NOTE: Applied separately in pool.rs with graceful degradation.
233+
-- CREATE INDEX IF NOT EXISTS skills_emb_idx ON skills(libsql_vector_idx(embedding));
234+
220235
-- ── Indexes ─────────────────────────────────────────────────────────
221236

222237
CREATE INDEX IF NOT EXISTS idx_gaps_epic ON gaps(epic_id);
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
//! Skill repository with native libSQL vector search.
2+
//!
3+
//! Stores skill metadata (name, description, plugin path) with a 384-dim
4+
//! BGE-small embedding for semantic matching via `vector_top_k`.
5+
//!
6+
//! Reuses `embed_one()`, `ensure_embedder()`, and `vec_to_literal()` from
7+
//! the memory module -- zero duplication.
8+
9+
use libsql::{params, Connection};
10+
11+
use crate::error::DbError;
12+
use crate::memory::{embed_one, vec_to_literal};
13+
14+
// ── Types ───────────────────────────────────────────────────────────
15+
16+
/// A skill match result from semantic search.
17+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
18+
pub struct SkillMatch {
19+
pub name: String,
20+
pub description: String,
21+
pub score: f64,
22+
}
23+
24+
/// A registered skill entry.
25+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26+
pub struct SkillEntry {
27+
pub id: i64,
28+
pub name: String,
29+
pub description: String,
30+
pub plugin_path: Option<String>,
31+
pub updated_at: String,
32+
}
33+
34+
// ── Repository ──────────────────────────────────────────────────────
35+
36+
/// Async repository for skill metadata + semantic vector search.
37+
pub struct SkillRepo {
38+
conn: Connection,
39+
}
40+
41+
impl SkillRepo {
42+
pub fn new(conn: Connection) -> Self {
43+
Self { conn }
44+
}
45+
46+
/// Insert or replace a skill. Auto-generates an embedding from
47+
/// `description` when the embedder is available; otherwise leaves the
48+
/// embedding NULL and logs a warning.
49+
pub async fn upsert(
50+
&self,
51+
name: &str,
52+
description: &str,
53+
plugin_path: Option<&str>,
54+
) -> Result<(), DbError> {
55+
let now = chrono::Utc::now().to_rfc3339();
56+
57+
self.conn
58+
.execute(
59+
"INSERT INTO skills (name, description, plugin_path, updated_at)
60+
VALUES (?1, ?2, ?3, ?4)
61+
ON CONFLICT(name) DO UPDATE SET
62+
description = excluded.description,
63+
plugin_path = excluded.plugin_path,
64+
updated_at = excluded.updated_at",
65+
params![
66+
name.to_string(),
67+
description.to_string(),
68+
plugin_path.map(String::from),
69+
now,
70+
],
71+
)
72+
.await?;
73+
74+
// Attempt to embed; swallow failures (NULL embedding is fine).
75+
match embed_one(description).await {
76+
Ok(vec) => {
77+
let lit = vec_to_literal(&vec);
78+
self.conn
79+
.execute(
80+
"UPDATE skills SET embedding = vector32(?1) WHERE name = ?2",
81+
params![lit, name.to_string()],
82+
)
83+
.await?;
84+
}
85+
Err(e) => {
86+
tracing::warn!(
87+
skill = name,
88+
error = %e,
89+
"embedder unavailable; skill inserted without embedding"
90+
);
91+
}
92+
}
93+
94+
Ok(())
95+
}
96+
97+
/// Semantic search: embed the query, find nearest skills via
98+
/// `vector_top_k`, convert L2 distance to cosine similarity, and
99+
/// filter by threshold.
100+
///
101+
/// Returns `Ok(vec![])` (not an error) if the embedder or vector
102+
/// index is unavailable -- graceful degradation.
103+
pub async fn match_skills(
104+
&self,
105+
query: &str,
106+
limit: usize,
107+
threshold: f64,
108+
) -> Result<Vec<SkillMatch>, DbError> {
109+
let vec = match embed_one(query).await {
110+
Ok(v) => v,
111+
Err(_) => return Ok(vec![]),
112+
};
113+
let lit = vec_to_literal(&vec);
114+
115+
let rows_result = self
116+
.conn
117+
.query(
118+
"SELECT s.name, s.description, top.distance
119+
FROM vector_top_k('skills_emb_idx', vector32(?1), ?2) AS top
120+
JOIN skills s ON s.rowid = top.id",
121+
params![lit, limit as i64],
122+
)
123+
.await;
124+
125+
let mut rows = match rows_result {
126+
Ok(r) => r,
127+
Err(_) => return Ok(vec![]), // vector index unavailable
128+
};
129+
130+
let mut out = Vec::new();
131+
while let Some(row) = rows.next().await? {
132+
let dist: f64 = row.get(2)?;
133+
// Convert L2 distance to cosine similarity.
134+
// For unit-norm vectors: cos_sim = 1 - (L2^2 / 2).
135+
let score = 1.0 - (dist * dist / 2.0);
136+
if score >= threshold {
137+
out.push(SkillMatch {
138+
name: row.get::<String>(0)?,
139+
description: row.get::<String>(1)?,
140+
score,
141+
});
142+
}
143+
}
144+
Ok(out)
145+
}
146+
147+
/// List all registered skills (for debugging / introspection).
148+
pub async fn list(&self) -> Result<Vec<SkillEntry>, DbError> {
149+
let mut rows = self
150+
.conn
151+
.query(
152+
"SELECT id, name, description, plugin_path, updated_at
153+
FROM skills
154+
ORDER BY name ASC",
155+
(),
156+
)
157+
.await?;
158+
159+
let mut out = Vec::new();
160+
while let Some(row) = rows.next().await? {
161+
out.push(SkillEntry {
162+
id: row.get::<i64>(0)?,
163+
name: row.get::<String>(1)?,
164+
description: row.get::<String>(2)?,
165+
plugin_path: row.get::<Option<String>>(3)?,
166+
updated_at: row.get::<String>(4)?,
167+
});
168+
}
169+
Ok(out)
170+
}
171+
}
172+
173+
// ── Tests ───────────────────────────────────────────────────────────
174+
175+
#[cfg(test)]
176+
mod tests {
177+
use super::*;
178+
use crate::pool::open_memory_async;
179+
180+
async fn fresh_repo() -> SkillRepo {
181+
let (_db, conn) = open_memory_async().await.expect("open memory db");
182+
let _ = Box::leak(Box::new(_db));
183+
SkillRepo::new(conn)
184+
}
185+
186+
#[tokio::test]
187+
async fn test_upsert_and_list() {
188+
let repo = fresh_repo().await;
189+
repo.upsert("plan", "Plan and design tasks", Some("/plugins/flow"))
190+
.await
191+
.expect("upsert");
192+
repo.upsert("work", "Execute implementation tasks", None)
193+
.await
194+
.expect("upsert");
195+
196+
let skills = repo.list().await.expect("list");
197+
assert_eq!(skills.len(), 2);
198+
assert_eq!(skills[0].name, "plan");
199+
assert_eq!(skills[1].name, "work");
200+
}
201+
202+
#[tokio::test]
203+
async fn test_upsert_replaces() {
204+
let repo = fresh_repo().await;
205+
repo.upsert("plan", "old description", None)
206+
.await
207+
.expect("upsert");
208+
repo.upsert("plan", "new description", Some("/new/path"))
209+
.await
210+
.expect("upsert");
211+
212+
let skills = repo.list().await.expect("list");
213+
assert_eq!(skills.len(), 1);
214+
assert_eq!(skills[0].description, "new description");
215+
assert_eq!(skills[0].plugin_path.as_deref(), Some("/new/path"));
216+
}
217+
218+
#[tokio::test]
219+
async fn test_match_skills_graceful_no_index() {
220+
// In-memory DB won't have vector index; should return empty, not error.
221+
let repo = fresh_repo().await;
222+
repo.upsert("plan", "Plan tasks", None)
223+
.await
224+
.expect("upsert");
225+
226+
let matches = repo
227+
.match_skills("planning", 5, 0.5)
228+
.await
229+
.expect("match_skills should not error");
230+
// May be empty if embedder or index is unavailable -- that's fine.
231+
assert!(matches.len() <= 5);
232+
}
233+
234+
/// Semantic match end-to-end. Gated behind `#[ignore]` because the
235+
/// first run downloads the BGE-small model (~130MB).
236+
#[tokio::test]
237+
#[ignore = "requires fastembed model (~130MB); run with --ignored"]
238+
async fn test_match_skills_semantic() {
239+
let repo = fresh_repo().await;
240+
repo.upsert("plan", "Design and architect implementation plans", None)
241+
.await
242+
.expect("upsert");
243+
repo.upsert("work", "Execute coding tasks and write code", None)
244+
.await
245+
.expect("upsert");
246+
repo.upsert("review", "Review code changes for quality", None)
247+
.await
248+
.expect("upsert");
249+
250+
let matches = repo
251+
.match_skills("architecture design", 2, 0.3)
252+
.await
253+
.expect("match_skills");
254+
assert!(!matches.is_empty(), "expected at least one match");
255+
assert_eq!(
256+
matches[0].name, "plan",
257+
"expected 'plan' as best match for architecture query"
258+
);
259+
}
260+
}

0 commit comments

Comments
 (0)