From b0f67697b495672d5aac5d9dc77fc1dc65c59b5b Mon Sep 17 00:00:00 2001 From: Nohe Bot Date: Fri, 22 May 2026 09:01:52 +0000 Subject: [PATCH] add project memory layer --- backend/migrations/059_memories.sql | 39 +++ backend/src/main.rs | 1 + backend/src/models/mod.rs | 32 +++ backend/src/routes/ai_chat/stream.rs | 52 ++++ backend/src/routes/memory.rs | 364 ++++++++++++++++++++++++++ backend/src/routes/mod.rs | 6 +- frontend/src/lib/types.ts | 18 ++ frontend/src/pages/ProjectContext.tsx | 130 ++++++++- 8 files changed, 640 insertions(+), 2 deletions(-) create mode 100644 backend/migrations/059_memories.sql create mode 100644 backend/src/routes/memory.rs diff --git a/backend/migrations/059_memories.sql b/backend/migrations/059_memories.sql new file mode 100644 index 00000000..782fd936 --- /dev/null +++ b/backend/migrations/059_memories.sql @@ -0,0 +1,39 @@ +-- 059: Baaton Memory Layer + +CREATE TABLE IF NOT EXISTS memories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id TEXT NOT NULL, + project_id UUID REFERENCES projects(id) ON DELETE CASCADE, + source TEXT NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'api', 'ai_chat', 'tldr', 'github', 'slack', 'email', 'memory_store')), + kind TEXT NOT NULL DEFAULT 'fact' CHECK (kind IN ('fact', 'decision', 'learning', 'constraint', 'risk', 'handoff', 'integration', 'note')), + content TEXT NOT NULL CHECK (length(trim(content)) > 0), + tags TEXT[] NOT NULL DEFAULT '{}', + confidence DOUBLE PRECISION NOT NULL DEFAULT 0.80 CHECK (confidence >= 0 AND confidence <= 1), + external_url TEXT, + embedding JSONB, + metadata JSONB NOT NULL DEFAULT '{}', + created_by TEXT, + created_by_name TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_memories_org_project_created ON memories(org_id, project_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_memories_org_kind ON memories(org_id, kind); +CREATE INDEX IF NOT EXISTS idx_memories_source ON memories(source); +CREATE INDEX IF NOT EXISTS idx_memories_tags ON memories USING GIN(tags); +CREATE INDEX IF NOT EXISTS idx_memories_content_search ON memories USING GIN(to_tsvector('simple', content)); + +CREATE OR REPLACE FUNCTION set_memories_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_memories_updated_at ON memories; +CREATE TRIGGER trg_memories_updated_at + BEFORE UPDATE ON memories + FOR EACH ROW + EXECUTE FUNCTION set_memories_updated_at(); diff --git a/backend/src/main.rs b/backend/src/main.rs index bf9c95a3..df134625 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -142,6 +142,7 @@ async fn main() -> anyhow::Result<()> { (56, include_str!("../migrations/056_pr_comment_job_type.sql")), (57, include_str!("../migrations/057_org_signing_keys.sql")), (58, include_str!("../migrations/058_source_slack.sql")), + (59, include_str!("../migrations/059_memories.sql")), ]; for &(version, sql) in migrations { diff --git a/backend/src/models/mod.rs b/backend/src/models/mod.rs index f7e6dd32..f4700d2b 100644 --- a/backend/src/models/mod.rs +++ b/backend/src/models/mod.rs @@ -347,6 +347,38 @@ pub struct AppendContextField { pub content: String, } +// ─── Project Memory ──────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct Memory { + pub id: Uuid, + pub org_id: String, + pub project_id: Option, + pub source: String, + pub kind: String, + pub content: String, + pub tags: Vec, + pub confidence: f64, + pub external_url: Option, + pub embedding: Option, + pub metadata: serde_json::Value, + pub created_by: Option, + pub created_by_name: Option, + pub updated_at: DateTime, + pub created_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct CreateMemory { + pub kind: Option, + pub content: String, + pub tags: Option>, + pub confidence: Option, + pub source: Option, + pub external_url: Option, + pub metadata: Option, +} + // ─── Project Template ────────────────────────────────── #[derive(Debug, Serialize, Deserialize, FromRow)] diff --git a/backend/src/routes/ai_chat/stream.rs b/backend/src/routes/ai_chat/stream.rs index 019a3c43..4de68331 100644 --- a/backend/src/routes/ai_chat/stream.rs +++ b/backend/src/routes/ai_chat/stream.rs @@ -103,6 +103,17 @@ pub(super) async fn build_project_context( learnings: Option, } + #[derive(sqlx::FromRow)] + struct MemoryRow { + project_id: uuid::Uuid, + source: String, + kind: String, + content: String, + tags: Vec, + confidence: f64, + created_at: chrono::DateTime, + } + let projects: Vec = if project_ids.is_empty() { sqlx::query_as::<_, ProjectRow>( "SELECT id, name, prefix FROM projects WHERE org_id = ANY($1::text[]) ORDER BY name ASC LIMIT 20", @@ -174,6 +185,24 @@ pub(super) async fn build_project_context( .await .unwrap_or_default(); + let memories: Vec = sqlx::query_as::<_, MemoryRow>( + r#" + SELECT project_id, source, kind, content, tags, confidence, created_at + FROM ( + SELECT m.*, row_number() OVER (PARTITION BY project_id ORDER BY confidence DESC, created_at DESC) AS rn + FROM memories m + WHERE m.org_id = ANY($1::text[]) AND m.project_id = ANY($2::uuid[]) + ) ranked + WHERE rn <= 5 + ORDER BY project_id, confidence DESC, created_at DESC + "#, + ) + .bind(org_ids) + .bind(&project_uuid_list) + .fetch_all(pool) + .await + .unwrap_or_default(); + let mut ctx = String::from("## Projets disponibles\n\n"); for project in &projects { ctx.push_str(&format!( @@ -219,6 +248,29 @@ pub(super) async fn build_project_context( } } + let project_memories: Vec<&MemoryRow> = memories + .iter() + .filter(|m| m.project_id == project.id) + .collect(); + if !project_memories.is_empty() { + ctx.push_str("- Relevant memories:\n"); + for m in project_memories { + let tags = if m.tags.is_empty() { + String::new() + } else { + format!(" tags={}", m.tags.join(",")) + }; + ctx.push_str(&format!( + " - [{} / {} / {:.2}{} / {}] {}\n", + m.kind, + m.source, + m.confidence, + tags, + m.created_at.format("%Y-%m-%d"), + truncate(&m.content, 500) + )); + } + } ctx.push('\n'); } diff --git a/backend/src/routes/memory.rs b/backend/src/routes/memory.rs new file mode 100644 index 00000000..3915b461 --- /dev/null +++ b/backend/src/routes/memory.rs @@ -0,0 +1,364 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + Extension, Json, +}; +use serde::Deserialize; +use serde_json::json; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::middleware::AuthUser; +use crate::models::{ApiResponse, CreateMemory, Memory}; + +#[derive(Debug, Deserialize)] +pub struct ListMemoryQuery { + pub q: Option, + pub kind: Option, + pub source: Option, + pub limit: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SearchMemoryRequest { + pub q: Option, + pub project_id: Option, + pub kind: Option, + pub source: Option, + pub tags: Option>, + pub limit: Option, +} + +#[derive(Debug, Clone)] +pub struct NewMemory { + pub org_id: String, + pub project_id: Option, + pub source: String, + pub kind: String, + pub content: String, + pub tags: Vec, + pub confidence: f64, + pub external_url: Option, + pub metadata: serde_json::Value, + pub created_by: Option, + pub created_by_name: Option, +} + +const VALID_KINDS: &[&str] = &[ + "fact", + "decision", + "learning", + "constraint", + "risk", + "handoff", + "integration", + "note", +]; +const VALID_SOURCES: &[&str] = &[ + "manual", + "api", + "ai_chat", + "tldr", + "github", + "slack", + "email", + "memory_store", +]; + +fn validate_kind(kind: &str) -> Result<(), (StatusCode, Json)> { + if VALID_KINDS.contains(&kind) { + Ok(()) + } else { + Err(( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": format!("Invalid memory kind '{}'.", kind), + "accepted_values": VALID_KINDS, + "field": "kind" + })), + )) + } +} + +fn validate_source(source: &str) -> Result<(), (StatusCode, Json)> { + if VALID_SOURCES.contains(&source) { + Ok(()) + } else { + Err(( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": format!("Invalid memory source '{}'.", source), + "accepted_values": VALID_SOURCES, + "field": "source" + })), + )) + } +} + +fn normalize_confidence(confidence: Option) -> f64 { + confidence.unwrap_or(0.8).clamp(0.0, 1.0) +} + +async fn project_org_id( + pool: &PgPool, + project_id: Uuid, + org_ids: &[String], +) -> Result)> { + sqlx::query_scalar::<_, String>( + "SELECT org_id FROM projects WHERE id = $1 AND org_id = ANY($2::text[])", + ) + .bind(project_id) + .bind(org_ids) + .fetch_optional(pool) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ) + })? + .ok_or_else(|| { + ( + StatusCode::NOT_FOUND, + Json(json!({"error": "Project not found"})), + ) + }) +} + +fn auth_org_ids(auth: &AuthUser) -> Result, (StatusCode, Json)> { + let org_id = auth.org_id.as_deref().ok_or_else(|| { + ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "Organization required"})), + ) + })?; + Ok(vec![org_id.to_string()]) +} + +pub async fn insert_memory(pool: &PgPool, input: NewMemory) -> Result { + sqlx::query_as::<_, Memory>( + r#" + INSERT INTO memories + (org_id, project_id, source, kind, content, tags, confidence, external_url, metadata, created_by, created_by_name) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING * + "#, + ) + .bind(&input.org_id) + .bind(input.project_id) + .bind(&input.source) + .bind(&input.kind) + .bind(&input.content) + .bind(&input.tags) + .bind(input.confidence) + .bind(&input.external_url) + .bind(&input.metadata) + .bind(&input.created_by) + .bind(&input.created_by_name) + .fetch_one(pool) + .await +} + +pub async fn create_project_memory( + Extension(auth): Extension, + State(pool): State, + Path(project_id): Path, + Json(body): Json, +) -> Result>, (StatusCode, Json)> { + let org_ids = auth_org_ids(&auth)?; + let org_id = project_org_id(&pool, project_id, &org_ids).await?; + let source = body.source.unwrap_or_else(|| "manual".to_string()); + let kind = body.kind.unwrap_or_else(|| "fact".to_string()); + validate_source(&source)?; + validate_kind(&kind)?; + + if body.content.trim().is_empty() { + return Err(( + StatusCode::BAD_REQUEST, + Json(json!({"error": "content is required"})), + )); + } + + let memory = insert_memory( + &pool, + NewMemory { + org_id, + project_id: Some(project_id), + source, + kind, + content: body.content.trim().to_string(), + tags: body.tags.unwrap_or_default(), + confidence: normalize_confidence(body.confidence), + external_url: body.external_url, + metadata: body.metadata.unwrap_or_else(|| json!({})), + created_by: Some(auth.user_id), + created_by_name: auth.display_name, + }, + ) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ) + })?; + + Ok(Json(ApiResponse::new(memory))) +} + +pub async fn list_project_memory( + Extension(auth): Extension, + State(pool): State, + Path(project_id): Path, + Query(query): Query, +) -> Result>>, (StatusCode, Json)> { + let org_ids = auth_org_ids(&auth)?; + let org_id = project_org_id(&pool, project_id, &org_ids).await?; + let limit = query.limit.unwrap_or(50).clamp(1, 200); + + if let Some(kind) = query.kind.as_deref() { + validate_kind(kind)?; + } + if let Some(source) = query.source.as_deref() { + validate_source(source)?; + } + + let memories = sqlx::query_as::<_, Memory>( + r#" + SELECT * FROM memories + WHERE org_id = $1 + AND project_id = $2 + AND ($3::text IS NULL OR kind = $3) + AND ($4::text IS NULL OR source = $4) + AND ( + $5::text IS NULL + OR content ILIKE '%' || $5 || '%' + OR EXISTS (SELECT 1 FROM unnest(tags) tag WHERE tag ILIKE '%' || $5 || '%') + ) + ORDER BY confidence DESC, created_at DESC + LIMIT $6 + "#, + ) + .bind(&org_id) + .bind(project_id) + .bind(&query.kind) + .bind(&query.source) + .bind(&query.q) + .bind(limit) + .fetch_all(&pool) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ) + })?; + + Ok(Json(ApiResponse::new(memories))) +} + +pub async fn delete_project_memory( + Extension(auth): Extension, + State(pool): State, + Path((project_id, memory_id)): Path<(Uuid, Uuid)>, +) -> Result>, (StatusCode, Json)> { + let org_ids = auth_org_ids(&auth)?; + project_org_id(&pool, project_id, &org_ids).await?; + + let deleted_id: Option = sqlx::query_scalar( + r#" + DELETE FROM memories + WHERE id = $1 AND project_id = $2 AND org_id = ANY($3::text[]) + RETURNING id + "#, + ) + .bind(memory_id) + .bind(project_id) + .bind(&org_ids) + .fetch_optional(&pool) + .await + .map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": e.to_string()})), + ) + })?; + + match deleted_id { + Some(id) => Ok(Json(ApiResponse::new(json!({ "deleted": true, "id": id })))), + None => Err(( + StatusCode::NOT_FOUND, + Json(json!({"error": "Memory not found"})), + )), + } +} + +pub async fn search_memory( + Extension(auth): Extension, + State(pool): State, + Json(body): Json, +) -> Result>>, (StatusCode, Json)> { + let org_ids = auth_org_ids(&auth)?; + let limit = body.limit.unwrap_or(20).clamp(1, 100); + + if let Some(kind) = body.kind.as_deref() { + validate_kind(kind)?; + } + if let Some(source) = body.source.as_deref() { + validate_source(source)?; + } + + let project_id = match body.project_id.as_deref() { + Some(raw) if !raw.trim().is_empty() => Some(raw.parse::().map_err(|_| { + ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "project_id must be a UUID"})), + ) + })?), + _ => None, + }; + + let memories = sqlx::query_as::<_, Memory>( + r#" + SELECT * FROM memories + WHERE org_id = ANY($1::text[]) + AND ($2::uuid IS NULL OR project_id = $2) + AND ($3::text IS NULL OR kind = $3) + AND ($4::text IS NULL OR source = $4) + AND ($5::text[] IS NULL OR tags && $5) + AND ( + $6::text IS NULL + OR content ILIKE '%' || $6 || '%' + OR EXISTS (SELECT 1 FROM unnest(tags) tag WHERE tag ILIKE '%' || $6 || '%') + OR to_tsvector('simple', content) @@ plainto_tsquery('simple', $6) + ) + ORDER BY + CASE WHEN $6::text IS NULL THEN 0 ELSE ts_rank_cd(to_tsvector('simple', content), plainto_tsquery('simple', $6)) END DESC, + confidence DESC, + created_at DESC + LIMIT $7 + "#, + ) + .bind(&org_ids) + .bind(project_id) + .bind(&body.kind) + .bind(&body.source) + .bind(&body.tags) + .bind(&body.q) + .bind(limit) + .fetch_all(&pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?; + + Ok(Json(ApiResponse::new(memories))) +} + +#[allow(dead_code)] +pub async fn record_memory_best_effort(pool: &PgPool, input: NewMemory) { + if input.content.trim().is_empty() { + return; + } + if let Err(e) = insert_memory(pool, input).await { + tracing::warn!("memory insert failed: {}", e); + } +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index 59457a26..548f0742 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -49,6 +49,7 @@ pub mod gamification; pub mod sse; pub mod event_bus; pub mod project_context; +pub mod memory; pub mod project_templates; mod dashboard; @@ -225,9 +226,12 @@ pub fn api_router(pool: PgPool, jwks: JwksKeys) -> Router { // README badge for a tracked GitHub repo (public, cached 5 min). // The `{repo}` segment may include a `.svg` suffix (handler strips it). .route("/public/badge/repo/{owner}/{repo}", get(badge::render)) - // Project Context + // Project Context + Memory Layer .route("/projects/{id}/context", get(project_context::get_or_create).patch(project_context::update)) .route("/projects/{id}/context/append", post(project_context::append)) + .route("/projects/{id}/memory", get(memory::list_project_memory).post(memory::create_project_memory)) + .route("/projects/{id}/memory/{memory_id}", delete(memory::delete_project_memory)) + .route("/memory/search", post(memory::search_memory)) // Dependency Graph .route("/projects/{id}/dependency-graph", get(relations::dependency_graph)) // Project Templates diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 73243c81..452644dc 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -411,6 +411,24 @@ export interface ProjectContext { created_at: string; } +export interface ProjectMemory { + id: string; + org_id: string; + project_id: string | null; + source: 'manual' | 'api' | 'ai_chat' | 'tldr' | 'github' | 'slack' | 'email' | 'memory_store'; + kind: 'fact' | 'decision' | 'learning' | 'constraint' | 'risk' | 'handoff' | 'integration' | 'note'; + content: string; + tags: string[]; + confidence: number; + external_url: string | null; + embedding: unknown | null; + metadata: Record; + created_by: string | null; + created_by_name: string | null; + updated_at: string; + created_at: string; +} + // ─── Project Template ───────────────────────────────── export interface ProjectTemplate { diff --git a/frontend/src/pages/ProjectContext.tsx b/frontend/src/pages/ProjectContext.tsx index 9fcb8cb5..b0022223 100644 --- a/frontend/src/pages/ProjectContext.tsx +++ b/frontend/src/pages/ProjectContext.tsx @@ -14,11 +14,12 @@ import { Layers, BookOpen, Network, Shield, Target, Lightbulb, ChevronDown, Clock, Search, CheckCircle2, AlertCircle, Loader2, FolderOpen, Braces, X, + Database, Plus, Tag, } from 'lucide-react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useApi } from '@/hooks/useApi'; import { cn } from '@/lib/utils'; -import type { ProjectContext as ProjectContextType, Project } from '@/lib/types'; +import type { ProjectContext as ProjectContextType, Project, ProjectMemory } from '@/lib/types'; // ─── Field config ───────────────────────────── @@ -104,6 +105,9 @@ export default function ProjectContext() { const [expandedFields, setExpandedFields] = useState>(new Set(CONTEXT_FIELDS.map(f => f.key))); const [localValues, setLocalValues] = useState>({}); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); + const [newMemoryContent, setNewMemoryContent] = useState(''); + const [newMemoryKind, setNewMemoryKind] = useState('fact'); + const [newMemoryTags, setNewMemoryTags] = useState(''); const debounceTimers = useRef>>({}); // Fetch all projects for the selector @@ -147,6 +151,33 @@ export default function ProjectContext() { } }, [context]); + const { data: memories = [], isLoading: memoriesLoading } = useQuery({ + queryKey: ['project-memory', currentProject?.id], + queryFn: () => apiClient.get(`/projects/${currentProject!.id}/memory?limit=20`), + enabled: !!currentProject?.id, + }); + + const createMemoryMutation = useMutation({ + mutationFn: (payload: { content: string; kind: ProjectMemory['kind']; tags: string[] }) => + apiClient.post(`/projects/${currentProject!.id}/memory`, { + ...payload, + source: 'manual', + confidence: 0.85, + }), + onSuccess: () => { + setNewMemoryContent(''); + setNewMemoryTags(''); + queryClient.invalidateQueries({ queryKey: ['project-memory', currentProject?.id] }); + }, + }); + + const deleteMemoryMutation = useMutation({ + mutationFn: (memoryId: string) => apiClient.del(`/projects/${currentProject!.id}/memory/${memoryId}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['project-memory', currentProject?.id] }); + }, + }); + // Save mutation with optimistic updates const saveMutation = useMutation({ mutationFn: (data: Record) => @@ -226,6 +257,16 @@ export default function ProjectContext() { navigate(`/projects/${project.slug}/context`); }; + const handleCreateMemory = () => { + const content = newMemoryContent.trim(); + if (!content || createMemoryMutation.isPending) return; + createMemoryMutation.mutate({ + content, + kind: newMemoryKind, + tags: newMemoryTags.split(',').map(t => t.trim()).filter(Boolean), + }); + }; + // Filled fields count for the selector badge const filledCount = CONTEXT_FIELDS.filter(f => localValues[f.key]?.trim()).length; @@ -404,6 +445,93 @@ export default function ProjectContext() { })} + {/* Memory Layer */} + +
+
+ +
+

Memory Layer

+

Atomic memories injected into the in-app agent context.

+
+
+ + {memories.length} memories + +
+ +
+ + setNewMemoryContent(e.target.value)} + onKeyDown={(e) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') handleCreateMemory(); + }} + placeholder="Remember a durable decision, gotcha, constraint..." + className="rounded-lg border border-border/50 bg-bg px-3 py-2 text-sm text-primary placeholder:text-tertiary outline-none focus:border-accent/50" + /> + setNewMemoryTags(e.target.value)} + placeholder="tags, comma separated" + className="rounded-lg border border-border/50 bg-bg px-3 py-2 text-sm text-primary placeholder:text-tertiary outline-none focus:border-accent/50" + /> + +
+ + {memoriesLoading ? ( +
Loading memories...
+ ) : memories.length === 0 ? ( +
+ No memories yet. TLDRs, Slack, email, GitHub and agent chat will enrich this automatically. +
+ ) : ( +
+ {memories.slice(0, 12).map(memory => ( +
+
+
+ {memory.kind} + {memory.source} + {Math.round(memory.confidence * 100)}% + {memory.tags.map(tag => ( + + {tag} + + ))} +
+ +
+

{memory.content}

+
+ ))} +
+ )} +
+ {/* Custom context (JSON) */} {context?.custom_context && Object.keys(context.custom_context).length > 0 && (