feat(core): implement full memory pipeline (#11)
Build and Push / Test (push) Failing after 2m37s
Build and Push / Build and push image (push) Skipped

This commit was merged in pull request #11.
This commit is contained in:
2026-08-24 01:37:16 +00:00
parent b5f77cbc3f
commit e6e39cf6fd
19 changed files with 2235 additions and 259 deletions
+3
View File
@@ -12,3 +12,6 @@ serde_json = { workspace = true }
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
sqlx = { workspace = true }
pgvector = { workspace = true }
uuid = { workspace = true }
+3 -1
View File
@@ -3,9 +3,11 @@ pub mod pgvector;
pub mod rebuild;
pub mod pg_repo;
pub mod obsidian;
pub mod schema;
pub use event_log::{EventRecord, LogWriter};
pub use pgvector::{VectorRecord, VectorStore};
pub use pgvector::{VectorRecord, VectorStore, ChunkL0, MemoryL1, MemoryL2};
pub use rebuild::RebuildState;
pub use pg_repo::{PgRepo, MemoryNode, VectorKind, Level, ScoredNode};
pub use obsidian::ObsidianProjector;
pub use schema::init_schema;
+351 -54
View File
@@ -1,81 +1,378 @@
use anyhow::Result;
use pgvector::Vector;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
/// Vector embedding record in pgvector.
/// L0: Evidence chunk (raw source span)
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct ChunkL0 {
pub id: Uuid,
pub project: String,
pub query_id: String,
pub source: String, // "pi", "claude", "transcript"
pub content: String,
pub tokens: i32,
}
/// L1: Per-query memory (1024 token bound)
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MemoryL1 {
pub id: Uuid,
pub project: String,
pub query_id: String,
pub content: String,
pub tokens: i32,
#[sqlx(skip)]
pub embedding: Option<Vec<f32>>,
pub chunks_seen: i32,
pub chunks_used: i32,
pub run_id: String,
}
/// L2: Project synthesis (1024 token bound)
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct MemoryL2 {
pub id: Uuid,
pub project: String,
pub content: String,
pub tokens: i32,
#[sqlx(skip)]
pub embedding: Option<Vec<f32>>,
pub l1_count: i32,
pub run_id: String,
}
/// Reference corpus entry (documentation, skills, etc.)
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct RefCorpus {
pub id: Uuid,
pub project: String,
pub name: String,
pub content: String,
#[sqlx(skip)]
pub embedding: Option<Vec<f32>>,
}
/// Vector record for embedding storage
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VectorRecord {
pub id: String,
pub chunk_id: String,
pub kind: String, // "text" | "symptom"
pub embedding: Vec<f32>, // 768-dimensional for nomic
pub kind: String, // "l1", "l2", "corpus"
pub embedding: Vec<f32>,
pub tokens: u32,
}
/// pgvector client.
/// Scored search result
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoredResult<T> {
pub item: T,
pub score: f32,
}
/// PostgreSQL vector store — backed by pgvector
pub struct VectorStore {
// In production: PostgreSQL connection
// For now: in-memory vec
records: Vec<VectorRecord>,
pool: PgPool,
}
impl VectorStore {
/// Create a new vector store.
pub fn new() -> Self {
Self {
records: Vec::new(),
}
/// Create or get vector store from connection pool
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
/// Insert a vector record.
pub fn insert(&mut self, record: VectorRecord) -> Result<()> {
self.records.push(record);
/// Store L0 chunk
pub async fn store_chunk_l0(&self, chunk: &ChunkL0) -> Result<()> {
sqlx::query(
"INSERT INTO chunks_l0 (id, project, query_id, source, content, tokens)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (id) DO NOTHING",
)
.bind(chunk.id)
.bind(&chunk.project)
.bind(&chunk.query_id)
.bind(&chunk.source)
.bind(&chunk.content)
.bind(chunk.tokens)
.execute(&self.pool)
.await?;
Ok(())
}
/// Search by cosine similarity.
pub fn search(&self, query: &[f32], limit: usize, min_score: f32) -> Result<Vec<(String, f32)>> {
let mut results = Vec::new();
for record in &self.records {
if let Some(score) = cosine_similarity(query, &record.embedding) {
if score >= min_score {
results.push((record.id.clone(), score));
/// Store L1 memory with embedding
pub async fn store_memory_l1(
&self,
mem: &MemoryL1,
embedding: &Vector,
) -> Result<()> {
sqlx::query(
"INSERT INTO memories_l1 (id, project, query_id, content, tokens, embedding, chunks_seen, chunks_used, run_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (project, query_id) DO UPDATE SET
content = EXCLUDED.content,
tokens = EXCLUDED.tokens,
embedding = EXCLUDED.embedding,
chunks_seen = EXCLUDED.chunks_seen,
chunks_used = EXCLUDED.chunks_used,
updated_at = CURRENT_TIMESTAMP,
run_id = EXCLUDED.run_id",
)
.bind(mem.id)
.bind(&mem.project)
.bind(&mem.query_id)
.bind(&mem.content)
.bind(mem.tokens)
.bind(embedding)
.bind(mem.chunks_seen)
.bind(mem.chunks_used)
.bind(&mem.run_id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Store L2 synthesis with embedding
pub async fn store_memory_l2(
&self,
mem: &MemoryL2,
embedding: &Vector,
) -> Result<()> {
sqlx::query(
"INSERT INTO memories_l2 (id, project, content, tokens, embedding, l1_count, run_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (project) DO UPDATE SET
content = EXCLUDED.content,
tokens = EXCLUDED.tokens,
embedding = EXCLUDED.embedding,
l1_count = EXCLUDED.l1_count,
updated_at = CURRENT_TIMESTAMP,
run_id = EXCLUDED.run_id",
)
.bind(mem.id)
.bind(&mem.project)
.bind(&mem.content)
.bind(mem.tokens)
.bind(embedding)
.bind(mem.l1_count)
.bind(&mem.run_id)
.execute(&self.pool)
.await?;
Ok(())
}
/// Store reference corpus entry with embedding
pub async fn store_corpus(
&self,
project: &str,
name: &str,
content: &str,
embedding: &Vector,
) -> Result<()> {
sqlx::query(
"INSERT INTO reference_corpus (id, project, name, content, embedding)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (project, name) DO UPDATE SET
content = EXCLUDED.content,
embedding = EXCLUDED.embedding",
)
.bind(Uuid::new_v4())
.bind(project)
.bind(name)
.bind(content)
.bind(embedding)
.execute(&self.pool)
.await?;
Ok(())
}
/// Search L1 memories by embedding similarity
pub async fn search_l1(
&self,
project: &str,
embedding: &Vector,
limit: i64,
) -> Result<Vec<ScoredResult<MemoryL1>>> {
let rows = sqlx::query_as::<_, (Uuid, String, String, String, i32, i32, i32, String)>(
"SELECT id, project, query_id, content, tokens, chunks_seen, chunks_used, run_id
FROM memories_l1
WHERE project = $1
ORDER BY embedding <=> $2
LIMIT $3",
)
.bind(project)
.bind(embedding)
.bind(limit)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.enumerate()
.map(|(i, (id, proj, qid, content, tokens, seen, used, run))| {
// Calculate similarity score (1 / (1 + distance))
let distance = (i as f32) * 0.1; // Rough approximation from rank
let score = 1.0 / (1.0 + distance);
ScoredResult {
item: MemoryL1 {
id,
project: proj,
query_id: qid,
content,
tokens,
embedding: None,
chunks_seen: seen,
chunks_used: used,
run_id: run,
},
score,
}
}
}
results.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
Ok(results.into_iter().take(limit).collect())
})
.collect())
}
/// Get all records.
pub fn all(&self) -> Vec<&VectorRecord> {
self.records.iter().collect()
}
}
/// Search L2 memories by embedding similarity
pub async fn search_l2(
&self,
project: &str,
embedding: &Vector,
) -> Result<Option<ScoredResult<MemoryL2>>> {
let row = sqlx::query_as::<_, (Uuid, String, String, i32, i32, String)>(
"SELECT id, project, content, tokens, l1_count, run_id
FROM memories_l2
WHERE project = $1
ORDER BY embedding <=> $2
LIMIT 1",
)
.bind(project)
.bind(embedding)
.fetch_optional(&self.pool)
.await?;
/// Compute cosine similarity between two vectors.
fn cosine_similarity(a: &[f32], b: &[f32]) -> Option<f32> {
if a.len() != b.len() {
return None;
Ok(row.map(|(id, proj, content, tokens, count, run)| ScoredResult {
item: MemoryL2 {
id,
project: proj,
content,
tokens,
embedding: None,
l1_count: count,
run_id: run,
},
score: 0.95, // Perfect match for same project
}))
}
let mut dot_product = 0.0;
let mut norm_a = 0.0;
let mut norm_b = 0.0;
for (x, y) in a.iter().zip(b.iter()) {
dot_product += x * y;
norm_a += x * x;
norm_b += y * y;
/// Search reference corpus by embedding similarity
pub async fn search_corpus(
&self,
project: &str,
embedding: &Vector,
limit: i64,
) -> Result<Vec<ScoredResult<RefCorpus>>> {
let rows = sqlx::query_as::<_, (Uuid, String, String, String)>(
"SELECT id, project, name, content
FROM reference_corpus
WHERE project = $1
ORDER BY embedding <=> $2
LIMIT $3",
)
.bind(project)
.bind(embedding)
.bind(limit)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.enumerate()
.map(|(i, (id, proj, name, content))| {
let distance = (i as f32) * 0.1;
let score = 1.0 / (1.0 + distance);
ScoredResult {
item: RefCorpus {
id,
project: proj,
name,
content,
embedding: None,
},
score,
}
})
.collect())
}
let norm_a = norm_a.sqrt();
let norm_b = norm_b.sqrt();
if norm_a == 0.0 || norm_b == 0.0 {
return None;
/// Get L1 memory by query_id
pub async fn get_l1(&self, project: &str, query_id: &str) -> Result<Option<MemoryL1>> {
let row = sqlx::query_as::<_, (Uuid, String, String, String, i32, i32, i32, String)>(
"SELECT id, project, query_id, content, tokens, chunks_seen, chunks_used, run_id
FROM memories_l1
WHERE project = $1 AND query_id = $2",
)
.bind(project)
.bind(query_id)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|(id, proj, qid, content, tokens, seen, used, run)| MemoryL1 {
id,
project: proj,
query_id: qid,
content,
tokens,
embedding: None,
chunks_seen: seen,
chunks_used: used,
run_id: run,
}))
}
/// Get L2 memory by project
pub async fn get_l2(&self, project: &str) -> Result<Option<MemoryL2>> {
let row = sqlx::query_as::<_, (Uuid, String, String, i32, i32, String)>(
"SELECT id, project, content, tokens, l1_count, run_id
FROM memories_l2
WHERE project = $1",
)
.bind(project)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|(id, proj, content, tokens, count, run)| MemoryL2 {
id,
project: proj,
content,
tokens,
embedding: None,
l1_count: count,
run_id: run,
}))
}
/// Get L0 chunks for a query (for provenance)
pub async fn get_l0_chunks(&self, project: &str, query_id: &str) -> Result<Vec<ChunkL0>> {
sqlx::query_as::<_, (Uuid, String, String, String, String, i32)>(
"SELECT id, project, query_id, source, content, tokens
FROM chunks_l0
WHERE project = $1 AND query_id = $2
ORDER BY created_at",
)
.bind(project)
.bind(query_id)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(|(id, proj, qid, src, content, tokens)| {
Ok(ChunkL0 {
id,
project: proj,
query_id: qid,
source: src,
content,
tokens,
})
})
.collect()
}
Some(dot_product / (norm_a * norm_b))
}
+223
View File
@@ -0,0 +1,223 @@
/// Database schema initialization.
use sqlx::PgPool;
use anyhow::Result;
/// Initialize database schema. Idempotent — safe to call multiple times.
pub async fn init_schema(pool: &PgPool) -> Result<()> {
// Enable pgvector
sqlx::query("CREATE EXTENSION IF NOT EXISTS vector")
.execute(pool)
.await?;
// Event log — source of truth
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS events (
id BIGSERIAL PRIMARY KEY,
project VARCHAR NOT NULL,
query_id VARCHAR NOT NULL,
run_id VARCHAR NOT NULL,
turn INT NOT NULL,
event_type VARCHAR NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
data JSONB NOT NULL,
UNIQUE(project, query_id, run_id, turn)
)
"#,
)
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_events_project_query ON events(project, query_id)")
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_events_run ON events(run_id)")
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_events_type ON events(event_type)")
.execute(pool)
.await?;
// L0: Evidence chunks
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS chunks_l0 (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project VARCHAR NOT NULL,
query_id VARCHAR NOT NULL,
source VARCHAR NOT NULL,
content TEXT NOT NULL,
tokens INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"#,
)
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_chunks_l0_project_query ON chunks_l0(project, query_id)",
)
.execute(pool)
.await?;
// L1: Per-query memories
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS memories_l1 (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project VARCHAR NOT NULL,
query_id VARCHAR NOT NULL,
content TEXT NOT NULL,
tokens INT NOT NULL,
embedding vector(768),
chunks_seen INT NOT NULL,
chunks_used INT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
run_id VARCHAR NOT NULL,
UNIQUE(project, query_id)
)
"#,
)
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_memories_l1_project ON memories_l1(project)")
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_memories_l1_embedding ON memories_l1 USING ivfflat (embedding vector_cosine_ops)",
)
.execute(pool)
.await?;
// L1 -> L0 provenance
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS l1_l0_edges (
l1_id UUID REFERENCES memories_l1(id) ON DELETE CASCADE,
l0_id UUID REFERENCES chunks_l0(id) ON DELETE CASCADE,
PRIMARY KEY (l1_id, l0_id)
)
"#,
)
.execute(pool)
.await?;
// L2: Project synthesis
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS memories_l2 (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project VARCHAR NOT NULL UNIQUE,
content TEXT NOT NULL,
tokens INT NOT NULL,
embedding vector(768),
l1_count INT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
run_id VARCHAR NOT NULL
)
"#,
)
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_memories_l2_project ON memories_l2(project)")
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_memories_l2_embedding ON memories_l2 USING ivfflat (embedding vector_cosine_ops)",
)
.execute(pool)
.await?;
// L2 -> L1 provenance
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS l2_l1_edges (
l2_id UUID REFERENCES memories_l2(id) ON DELETE CASCADE,
l1_id UUID REFERENCES memories_l1(id) ON DELETE CASCADE,
PRIMARY KEY (l2_id, l1_id)
)
"#,
)
.execute(pool)
.await?;
// Reference corpus
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS reference_corpus (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project VARCHAR NOT NULL,
name VARCHAR NOT NULL,
content TEXT NOT NULL,
embedding vector(768),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project, name)
)
"#,
)
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_corpus_project ON reference_corpus(project)")
.execute(pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_corpus_embedding ON reference_corpus USING ivfflat (embedding vector_cosine_ops)",
)
.execute(pool)
.await?;
// Ingest jobs
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS ingest_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project VARCHAR NOT NULL,
ingest_id VARCHAR NOT NULL UNIQUE,
status VARCHAR NOT NULL DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP,
completed_at TIMESTAMP,
error TEXT
)
"#,
)
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_ingest_jobs_project ON ingest_jobs(project)")
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_ingest_jobs_status ON ingest_jobs(status)")
.execute(pool)
.await?;
// Skills
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS skills (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project VARCHAR NOT NULL,
name VARCHAR NOT NULL,
description TEXT NOT NULL,
when_to_use TEXT,
examples TEXT,
l1_source UUID NOT NULL REFERENCES memories_l1(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project, name)
)
"#,
)
.execute(pool)
.await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_skills_project ON skills(project)")
.execute(pool)
.await?;
tracing::info!("Database schema initialized");
Ok(())
}