diff --git a/.env.example b/.env.example
index 822229ea1..06659aeff 100644
--- a/.env.example
+++ b/.env.example
@@ -135,6 +135,17 @@ SEARXNG_SECRET=
RCA_OPTIMIZE_COSTS=false
GEMINI_DISABLE_THINKING=
+# Embedding model for alert correlation (optional).
+# Improves alert-to-incident grouping with semantic similarity.
+# Requires a DIRECT API key — OpenRouter does NOT support embeddings.
+# If left empty, uses Jaccard token similarity (works fine for most cases).
+# Format: provider/model-name
+# Examples:
+# openai/text-embedding-3-small (requires OPENAI_API_KEY)
+# google/gemini-embedding-2 (requires GOOGLE_AI_API_KEY)
+# bedrock/amazon.titan-embed-text-v2:0 (requires BEDROCK_ACCESS_KEY_ID)
+EMBEDDING_MODEL=
+
# Multi-agent RCA orchestrator (opt-in). When false (default), background RCA
# uses the legacy single-agent ReAct loop with RCA_MODEL. When true, a lead
# orchestrator triages each RCA and may fan out parallel read-only sub-agents.
@@ -333,12 +344,6 @@ USE_UNTRUSTED_NODES=
NEXT_PUBLIC_KUBECTL_AGENT_CHART_URL=
# -----------------------------------------------------------------------------
-# Weaviate (Vector Database)
-# -----------------------------------------------------------------------------
-WEAVIATE_HOST=weaviate
-WEAVIATE_PORT=8080
-WEAVIATE_GRPC_PORT=50051
-
# -----------------------------------------------------------------------------
# SSL/TLS for External Services
# -----------------------------------------------------------------------------
@@ -351,9 +356,6 @@ POSTGRES_SSLROOTCERT=
REDIS_SSL_CA_CERTS=
REDIS_SSL_CERT_REQS=
-# Weaviate: set to true for external instances with HTTPS+gRPC-TLS
-WEAVIATE_SECURE=false
-
# -----------------------------------------------------------------------------
# Memgraph (Graph Database)
# -----------------------------------------------------------------------------
diff --git a/.github/workflows/publish-airtight.yml b/.github/workflows/publish-airtight.yml
index 383b07198..73d941a62 100644
--- a/.github/workflows/publish-airtight.yml
+++ b/.github/workflows/publish-airtight.yml
@@ -52,8 +52,6 @@ jobs:
amazon/aws-cli:2.34.6
memgraph/memgraph-mage:3.8.1
memgraph/lab:3.8.0
- cr.weaviate.io/semitechnologies/weaviate:1.27.6
- cr.weaviate.io/semitechnologies/transformers-inference:sentence-transformers-all-MiniLM-L6-v2
steps:
- name: Free disk space
diff --git a/AGENTS.md b/AGENTS.md
index 3362e60e6..83600a2b8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -17,10 +17,10 @@
- **Important**: Always update both `docker-compose.yaml` and `docker-compose.prod-local.yml` together to keep environment variables in sync.
## Architecture
-- **Docker Compose stack**: aurora-server (Flask API on :5080), celery_worker (background tasks), chatbot (WebSocket on :5006), frontend (Next.js on :3000), postgres (:5432), weaviate (vector DB :8080), redis (:6379), vault (secrets :8200), seaweedfs (object storage :8333)
+- **Docker Compose stack**: aurora-server (Flask API on :5080), celery_worker (background tasks), chatbot (WebSocket on :5006), frontend (Next.js on :3000), postgres (:5432), redis (:6379), vault (secrets :8200), seaweedfs (object storage :8333)
- **Backend** (server/): Flask REST API (main_compute.py), WebSocket chatbot (main_chatbot.py), Celery tasks, connectors for GCP/AWS/Azure/Datadog/New Relic/Grafana, LangGraph agent workflow
- **Frontend** (client/): Next.js 15, TypeScript, Tailwind CSS, shadcn/ui components, Auth.js authentication, path alias `@/*` → `./src/*`
-- **Database**: PostgreSQL (aurora_db), Weaviate for semantic search, Redis for Celery queue
+- **Database**: PostgreSQL (aurora_db), Redis for Celery queue
- **Secrets**: HashiCorp Vault (KV v2 engine at `aurora` mount)
- **Object Storage**: S3-compatible via SeaweedFS (default), supports AWS S3, Cloudflare R2, MinIO, etc.
- **Config**: Environment in `./.env`, GCP service account in `server/connectors/gcp_connector/*.json`
diff --git a/README.md b/README.md
index d8716225c..2a0459bb4 100644
--- a/README.md
+++ b/README.md
@@ -271,7 +271,6 @@ aurora/
| Backend | Python, Flask, Celery |
| Frontend | Next.js, TypeScript |
| Graph DB | Memgraph |
-| Vector Store | Weaviate |
| Secrets | HashiCorp Vault, AWS Secrets Manager |
| Storage | PostgreSQL, Redis, S3-compatible |
diff --git a/client/next.config.ts b/client/next.config.ts
index a3190982f..ea99d5921 100644
--- a/client/next.config.ts
+++ b/client/next.config.ts
@@ -23,6 +23,12 @@ const nextConfig: NextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
+ experimental: {
+ serverActions: {
+ bodySizeLimit: "50mb",
+ },
+ middlewareClientMaxBodySize: "50mb",
+ },
cacheMaxMemorySize: 0,
poweredByHeader: false,
webpack: (config, { isServer, dev }) => {
diff --git a/client/src/app/api/feedback/route.ts b/client/src/app/api/feedback/route.ts
deleted file mode 100644
index 3409271f2..000000000
--- a/client/src/app/api/feedback/route.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server';
-import { auth } from '@/auth';
-import { readFeedback, writeFeedback, saveImage, StoredFeedback } from '@/lib/feedback-storage';
-
-//Interface for feedback data received from the frontend form
-interface FeedbackData {
- description: string;
- userAgent: string;
- url: string;
- userId?: string;
- userEmail?: string;
-}
-
-/**
- * Submits new feedback from authenticated users
- *
- * Security: Requires authentication (any authenticated user can submit)
- *
- * Request: FormData containing:
- * - feedbackData: JSON string with feedback details
- * - image_0, image_1, image_2: Optional image files (max 3)
- *
- * Usage: Called when user clicks "Send" on feedback form
- */
-export async function POST(req: NextRequest) {
- try {
- const session = await auth();
- if (!session?.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
-
- // Parse form data from request
- const formData = await req.formData();
- const feedbackDataStr = formData.get('feedbackData') as string;
-
- // Validate required feedback data exists
- if (!feedbackDataStr) return NextResponse.json({ error: 'Missing feedback data' }, { status: 400 });
-
- // Parse and validate feedback content
- const feedbackData: FeedbackData = JSON.parse(feedbackDataStr);
- if (!feedbackData.description?.trim()) {
- return NextResponse.json({ error: 'Feedback description is required' }, { status: 400 });
- }
-
- // Generate unique ID for this feedback
- const feedbackId = `feedback_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
-
- // Process any uploaded images (max 3)
- const images = [];
- const entries = Array.from(formData.entries());
-
- for (const [key, value] of entries) {
- // Look for image fields (image_0, image_1, image_2)
- if (key.startsWith('image_') && value && typeof value === 'object' && 'arrayBuffer' in value) {
- const imageData = await saveImage(value as File, feedbackId);
- images.push(imageData);
- }
- }
-
- // Create complete feedback object
- const newFeedback: StoredFeedback = {
- id: feedbackId,
- description: feedbackData.description.trim(),
- userAgent: feedbackData.userAgent,
- url: feedbackData.url,
- userId: session.userId,
- userEmail: session.user?.email || '',
- timestamp: new Date().toISOString(),
- images: images,
- status: "new" // All new feedback starts with "new" status
- };
-
- // Save to storage
- const existingFeedback = await readFeedback();
- existingFeedback.push(newFeedback);
- await writeFeedback(existingFeedback);
-
- // Log successful submission for monitoring
- // console.log('Feedback received and saved:', {
- // id: newFeedback.id,
- // description: newFeedback.description.substring(0, 100) + '...',
- // userEmail: newFeedback.userEmail,
- // imagesCount: images.length,
- // totalImageSize: images.reduce((sum, img) => sum + img.size, 0),
- // });
-
- return NextResponse.json({
- success: true,
- message: 'Feedback submitted successfully',
- feedbackId: newFeedback.id
- });
- } catch (error) {
- console.error('Error processing feedback:', error);
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
- }
-}
\ No newline at end of file
diff --git a/client/src/app/api/incidents/[id]/feedback/route.ts b/client/src/app/api/incidents/[id]/feedback/route.ts
deleted file mode 100644
index 538e9d2bd..000000000
--- a/client/src/app/api/incidents/[id]/feedback/route.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server';
-import { getAuthenticatedUser } from '@/lib/auth-helper';
-
-const API_BASE_URL = process.env.BACKEND_URL;
-
-export async function GET(
- request: NextRequest,
- { params }: { params: { id: string } }
-) {
- try {
- if (!API_BASE_URL) {
- return NextResponse.json(
- { error: 'BACKEND_URL not configured' },
- { status: 500 }
- );
- }
-
- const authResult = await getAuthenticatedUser();
-
- if (authResult instanceof NextResponse) {
- return authResult;
- }
-
- const { headers: authHeaders } = authResult;
- const { id } = await params;
-
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 30000);
-
- try {
- const response = await fetch(`${API_BASE_URL}/api/incidents/${id}/feedback`, {
- method: 'GET',
- headers: authHeaders,
- credentials: 'include',
- cache: 'no-store',
- signal: controller.signal,
- });
-
- clearTimeout(timeoutId);
-
- if (!response.ok) {
- const text = await response.text();
- return NextResponse.json(
- { error: text || 'Failed to get feedback' },
- { status: response.status }
- );
- }
-
- const data = await response.json();
- return NextResponse.json(data);
- } catch (fetchError: unknown) {
- clearTimeout(timeoutId);
- if (fetchError instanceof Error && fetchError.name === 'AbortError') {
- return NextResponse.json({ error: 'Request timeout' }, { status: 504 });
- }
- throw fetchError;
- }
- } catch (error) {
- console.error('[api/incidents/[id]/feedback] GET Error:', error);
- return NextResponse.json({ error: 'Failed to get feedback' }, { status: 500 });
- }
-}
-
-export async function POST(
- request: NextRequest,
- { params }: { params: { id: string } }
-) {
- try {
- if (!API_BASE_URL) {
- return NextResponse.json(
- { error: 'BACKEND_URL not configured' },
- { status: 500 }
- );
- }
-
- const authResult = await getAuthenticatedUser();
-
- if (authResult instanceof NextResponse) {
- return authResult;
- }
-
- const { headers: authHeaders } = authResult;
- const { id } = await params;
- const body = await request.json();
-
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 30000);
-
- try {
- const response = await fetch(`${API_BASE_URL}/api/incidents/${id}/feedback`, {
- method: 'POST',
- headers: {
- ...authHeaders,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(body),
- credentials: 'include',
- cache: 'no-store',
- signal: controller.signal,
- });
-
- clearTimeout(timeoutId);
-
- if (!response.ok) {
- const data = await response.json().catch(() => ({}));
- return NextResponse.json(
- { error: data.error || 'Failed to submit feedback' },
- { status: response.status }
- );
- }
-
- const data = await response.json();
- return NextResponse.json(data, { status: 201 });
- } catch (fetchError: unknown) {
- clearTimeout(timeoutId);
- if (fetchError instanceof Error && fetchError.name === 'AbortError') {
- return NextResponse.json({ error: 'Request timeout' }, { status: 504 });
- }
- throw fetchError;
- }
- } catch (error) {
- console.error('[api/incidents/[id]/feedback] POST Error:', error);
- return NextResponse.json({ error: 'Failed to submit feedback' }, { status: 500 });
- }
-}
diff --git a/client/src/app/api/proxy/knowledge-base/[...path]/route.ts b/client/src/app/api/proxy/memory/[...path]/route.ts
similarity index 68%
rename from client/src/app/api/proxy/knowledge-base/[...path]/route.ts
rename to client/src/app/api/proxy/memory/[...path]/route.ts
index e15981fd1..244515783 100644
--- a/client/src/app/api/proxy/knowledge-base/[...path]/route.ts
+++ b/client/src/app/api/proxy/memory/[...path]/route.ts
@@ -6,8 +6,8 @@ async function handler(
{ params }: { params: Promise<{ path: string[] }> },
) {
const { path } = await params;
- const backendPath = '/api/knowledge-base/' + path.join('/');
- return forwardRequest(request, request.method, backendPath, 'knowledge-base');
+ const backendPath = '/api/memory/' + path.join('/');
+ return forwardRequest(request, request.method, backendPath, 'memory');
}
export { handler as GET, handler as POST, handler as PUT, handler as DELETE };
diff --git a/client/src/app/api/user/preferences/aurora-learn/route.ts b/client/src/app/api/user/preferences/aurora-learn/route.ts
deleted file mode 100644
index d9572d2fc..000000000
--- a/client/src/app/api/user/preferences/aurora-learn/route.ts
+++ /dev/null
@@ -1,117 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server';
-import { getAuthenticatedUser } from '@/lib/auth-helper';
-
-const API_BASE_URL = process.env.BACKEND_URL;
-
-export async function GET(request: NextRequest) {
- try {
- if (!API_BASE_URL) {
- return NextResponse.json(
- { error: 'BACKEND_URL not configured' },
- { status: 500 }
- );
- }
-
- const authResult = await getAuthenticatedUser();
-
- if (authResult instanceof NextResponse) {
- return authResult;
- }
-
- const { headers: authHeaders } = authResult;
-
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 30000);
-
- try {
- const response = await fetch(`${API_BASE_URL}/api/user/preferences/aurora-learn`, {
- method: 'GET',
- headers: authHeaders,
- credentials: 'include',
- cache: 'no-store',
- signal: controller.signal,
- });
-
- clearTimeout(timeoutId);
-
- if (!response.ok) {
- const text = await response.text();
- return NextResponse.json(
- { error: text || 'Failed to get Aurora Learn setting' },
- { status: response.status }
- );
- }
-
- const data = await response.json();
- return NextResponse.json(data);
- } catch (fetchError: unknown) {
- clearTimeout(timeoutId);
- if (fetchError instanceof Error && fetchError.name === 'AbortError') {
- return NextResponse.json({ error: 'Request timeout' }, { status: 504 });
- }
- throw fetchError;
- }
- } catch (error) {
- console.error('[api/user/preferences/aurora-learn] GET Error:', error);
- return NextResponse.json({ error: 'Failed to get Aurora Learn setting' }, { status: 500 });
- }
-}
-
-export async function PUT(request: NextRequest) {
- try {
- if (!API_BASE_URL) {
- return NextResponse.json(
- { error: 'BACKEND_URL not configured' },
- { status: 500 }
- );
- }
-
- const authResult = await getAuthenticatedUser();
-
- if (authResult instanceof NextResponse) {
- return authResult;
- }
-
- const { headers: authHeaders } = authResult;
- const body = await request.json();
-
- const controller = new AbortController();
- const timeoutId = setTimeout(() => controller.abort(), 30000);
-
- try {
- const response = await fetch(`${API_BASE_URL}/api/user/preferences/aurora-learn`, {
- method: 'PUT',
- headers: {
- ...authHeaders,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(body),
- credentials: 'include',
- cache: 'no-store',
- signal: controller.signal,
- });
-
- clearTimeout(timeoutId);
-
- if (!response.ok) {
- const data = await response.json().catch(() => ({}));
- return NextResponse.json(
- { error: data.error || 'Failed to update Aurora Learn setting' },
- { status: response.status }
- );
- }
-
- const data = await response.json();
- return NextResponse.json(data);
- } catch (fetchError: unknown) {
- clearTimeout(timeoutId);
- if (fetchError instanceof Error && fetchError.name === 'AbortError') {
- return NextResponse.json({ error: 'Request timeout' }, { status: 504 });
- }
- throw fetchError;
- }
- } catch (error) {
- console.error('[api/user/preferences/aurora-learn] PUT Error:', error);
- return NextResponse.json({ error: 'Failed to update Aurora Learn setting' }, { status: 500 });
- }
-}
diff --git a/client/src/app/incidents/components/IncidentCard.tsx b/client/src/app/incidents/components/IncidentCard.tsx
index 13e8ef48f..a89827c4d 100644
--- a/client/src/app/incidents/components/IncidentCard.tsx
+++ b/client/src/app/incidents/components/IncidentCard.tsx
@@ -31,7 +31,6 @@ import CitationBadge from './CitationBadge';
import CitationModal from './CitationModal';
import SuggestionModal from './SuggestionModal';
import FixSuggestionModal from './FixSuggestionModal';
-import IncidentFeedback from './IncidentFeedback';
import CorrelatedAlertsSection from './CorrelatedAlertsSection';
import RecentAlertsSection from './RecentAlertsSection';
import PostmortemPanel from './PostmortemPanel';
@@ -763,13 +762,6 @@ export default function IncidentCard({ incident, duration, showThoughts, onToggl
- {/* Feedback Section - only show when analysis is complete */}
- {incident.auroraStatus === 'complete' && (
-
-
-
- )}
-
{/* Action Runs linked to this incident (collapsible, lazy-loaded) */}
diff --git a/client/src/app/incidents/components/IncidentFeedback.tsx b/client/src/app/incidents/components/IncidentFeedback.tsx
deleted file mode 100644
index 8746cc234..000000000
--- a/client/src/app/incidents/components/IncidentFeedback.tsx
+++ /dev/null
@@ -1,266 +0,0 @@
-'use client';
-
-import { useState, useEffect, ReactNode } from 'react';
-import { ThumbsUp, ThumbsDown, Loader2, LucideIcon } from 'lucide-react';
-import { Button } from '@/components/ui/button';
-import { Textarea } from '@/components/ui/textarea';
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from '@/components/ui/tooltip';
-import {
- incidentFeedbackService,
- FeedbackType,
- IncidentFeedback as FeedbackData,
-} from '@/lib/services/incident-feedback';
-import { ApiError } from '@/lib/services/api-client';
-import { useToast } from '@/hooks/use-toast';
-
-interface IncidentFeedbackProps {
- incidentId: string;
- existingFeedback?: FeedbackData | null;
- readOnly?: boolean;
-}
-
-interface FeedbackButtonProps {
- icon: LucideIcon;
- tooltip: string;
- onClick?: () => void;
- disabled?: boolean;
- isLoading?: boolean;
- isSelected?: boolean;
- variant: 'helpful' | 'not_helpful';
-}
-
-function FeedbackButton({
- icon: Icon,
- tooltip,
- onClick,
- disabled,
- isLoading,
- isSelected,
- variant,
-}: FeedbackButtonProps): ReactNode {
- const colorClasses = variant === 'helpful'
- ? { selected: 'bg-green-500/20 text-green-400', hover: 'text-white hover:text-green-400 hover:bg-green-500/10' }
- : { selected: 'bg-red-500/20 text-red-400', hover: 'text-white hover:text-red-400 hover:bg-red-500/10' };
-
- return (
-
-
-
-
- {isLoading ? : }
-
-
-
- {tooltip}
-
-
-
- );
-}
-
-type FeedbackState = 'idle' | 'selecting' | 'submitting' | 'submitted';
-
-export default function IncidentFeedback({
- incidentId,
- existingFeedback,
- readOnly = false,
-}: IncidentFeedbackProps): ReactNode {
- const { toast } = useToast();
- const [state, setState] = useState
(existingFeedback ? 'submitted' : 'idle');
- const [selectedType, setSelectedType] = useState(
- existingFeedback?.feedbackType || null
- );
- const [comment, setComment] = useState('');
- const [isLoading, setIsLoading] = useState(false);
- const [feedback, setFeedback] = useState(existingFeedback || null);
-
- // Fetch existing feedback on mount if not provided
- useEffect(() => {
- if (!existingFeedback && incidentId) {
- incidentFeedbackService.getFeedback(incidentId)
- .then((data) => {
- if (data) {
- setFeedback(data);
- setSelectedType(data.feedbackType);
- setState('submitted');
- }
- })
- .catch((err) => {
- console.error('Error fetching feedback:', err);
- });
- }
- }, [incidentId, existingFeedback]);
-
- const handleThumbsClick = (type: FeedbackType) => {
- if (state === 'submitted' || readOnly) return;
-
- setSelectedType(type);
- if (type === 'helpful') {
- // Submit immediately for helpful
- handleSubmit(type);
- } else {
- // Show comment field for not helpful
- setState('selecting');
- }
- };
-
- const handleSubmit = async (type?: FeedbackType, skipComment?: boolean) => {
- const feedbackType = type || selectedType;
- if (!feedbackType) return;
-
- setIsLoading(true);
- setState('submitting');
-
- try {
- const response = await incidentFeedbackService.submitFeedback(
- incidentId,
- feedbackType,
- skipComment ? undefined : comment
- );
-
- setFeedback({
- id: response.feedbackId,
- feedbackType: response.feedbackType,
- comment: skipComment ? undefined : comment,
- createdAt: response.createdAt,
- });
- setState('submitted');
-
- toast({
- title: 'Feedback submitted',
- description: feedbackType === 'helpful'
- ? 'Thanks! Aurora will learn from this analysis.'
- : 'Thanks for your feedback.',
- });
- } catch (err) {
- console.error('Error submitting feedback:', err);
- const apiError = err as ApiError;
- const message = apiError.message || 'Failed to submit feedback';
-
- if (apiError.code === 'AURORA_LEARN_DISABLED') {
- toast({
- title: 'Aurora Learn is disabled',
- description: 'Enable Aurora Learn in Settings > Knowledge Base to provide feedback.',
- variant: 'destructive',
- });
- } else {
- toast({
- title: 'Failed to submit feedback',
- description: message,
- variant: 'destructive',
- });
- }
-
- setState('idle');
- setSelectedType(null);
- } finally {
- setIsLoading(false);
- }
- };
-
- const feedbackType = feedback?.feedbackType || selectedType;
-
- // Selecting not helpful (with comment)
- if (state === 'selecting' && selectedType === 'not_helpful') {
- return (
-
-
- handleThumbsClick('helpful')}
- disabled={isLoading}
- variant="helpful"
- />
-
-
-
-
-
- Optional: What could be improved?
-
-
-
- );
- }
-
- // Default and submitted states - just show icons
- const isSubmitted = state === 'submitted';
- const buttonsDisabled = isSubmitted || isLoading || readOnly;
-
- return (
-
- handleThumbsClick('helpful')}
- disabled={buttonsDisabled}
- isLoading={isLoading && selectedType === 'helpful'}
- isSelected={isSubmitted && feedbackType === 'helpful'}
- variant="helpful"
- />
- handleThumbsClick('not_helpful')}
- disabled={buttonsDisabled}
- isSelected={isSubmitted && feedbackType === 'not_helpful'}
- variant="not_helpful"
- />
- {readOnly && (
- Read-only
- )}
-
- );
-}
diff --git a/client/src/app/manage-org/knowledge-base/page.tsx b/client/src/app/manage-org/knowledge-base/page.tsx
index 34de5a67f..fbb265610 100644
--- a/client/src/app/manage-org/knowledge-base/page.tsx
+++ b/client/src/app/manage-org/knowledge-base/page.tsx
@@ -1,11 +1,12 @@
"use client";
-import { KnowledgeBaseSettings } from "@/components/KnowledgeBaseSettings";
+import { MemorySettings } from "@/components/MemorySettings";
-export default function KnowledgeBasePage() {
+// Legacy route — Knowledge Base was replaced by Memory (Settings → Memory tab).
+export default function LegacyKnowledgeBasePage() {
return (
-
+
);
}
diff --git a/client/src/components/KnowledgeBaseSettings.tsx b/client/src/components/KnowledgeBaseSettings.tsx
deleted file mode 100644
index 73de81578..000000000
--- a/client/src/components/KnowledgeBaseSettings.tsx
+++ /dev/null
@@ -1,628 +0,0 @@
-"use client";
-
-import { useState, useEffect, useCallback, useRef } from "react";
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { Button } from "@/components/ui/button";
-import { Textarea } from "@/components/ui/textarea";
-import { Badge } from "@/components/ui/badge";
-import { Switch } from "@/components/ui/switch";
-import { useToast } from "@/hooks/use-toast";
-import { useUserId } from "@/hooks/use-user-id";
-import {
- Loader2,
- Upload,
- Trash2,
- FileText,
- BookOpen,
- Brain,
- Sparkles,
- Check,
- AlertCircle,
- RefreshCw,
-} from "lucide-react";
-import { userPreferencesService } from "@/lib/services/incident-feedback";
-import { useUser } from "@/hooks/useAuthHooks";
-import { DiscoverySettings } from "@/components/DiscoverySettings";
-import { canWrite as checkCanWrite } from "@/lib/roles";
-
-const MEMORY_MAX_LENGTH = 5000;
-
-interface Document {
- id: string;
- filename: string;
- original_filename: string;
- file_type: string;
- file_size_bytes: number;
- status: "uploading" | "processing" | "ready" | "failed";
- error_message: string | null;
- chunk_count: number;
- created_at: string;
- updated_at: string;
-}
-
-interface Usage {
- document_count: number;
- document_limit: number;
- storage_used_mb: number;
- storage_limit_mb: number;
-}
-
-export function KnowledgeBaseSettings() {
- const { userId, isLoading: userLoading } = useUserId();
- const { user } = useUser();
- const canWrite = checkCanWrite(user?.role);
- const { toast } = useToast();
-
- // Memory state
- const [memoryContent, setMemoryContent] = useState("");
- const [originalMemory, setOriginalMemory] = useState("");
- const [isLoadingMemory, setIsLoadingMemory] = useState(true);
- const [isSavingMemory, setIsSavingMemory] = useState(false);
-
- // Documents state
- const [documents, setDocuments] = useState([]);
- const [usage, setUsage] = useState(null);
- const [isLoadingDocs, setIsLoadingDocs] = useState(true);
- const [isUploading, setIsUploading] = useState(false);
- const [deletingId, setDeletingId] = useState(null);
- const fileInputRef = useRef(null);
-
- // Polling for processing documents
- const [pollingIds, setPollingIds] = useState>(new Set());
-
- // Aurora Learn state
- const [auroraLearnEnabled, setAuroraLearnEnabled] = useState(true);
- const [isLoadingLearn, setIsLoadingLearn] = useState(true);
- const [isTogglingLearn, setIsTogglingLearn] = useState(false);
-
- const hasMemoryChanges = memoryContent !== originalMemory;
-
- // Fetch memory
- const fetchMemory = useCallback(async () => {
- if (!userId) {
- setIsLoadingMemory(false);
- return;
- }
-
- try {
- const res = await fetch(`/api/proxy/knowledge-base/memory`);
-
- if (res.ok) {
- const data = await res.json();
- setMemoryContent(data.content || "");
- setOriginalMemory(data.content || "");
- }
- } catch (error) {
- console.error("Failed to fetch memory:", error);
- } finally {
- setIsLoadingMemory(false);
- }
- }, [userId]);
-
- // Fetch documents
- const fetchDocuments = useCallback(async () => {
- if (!userId) {
- setIsLoadingDocs(false);
- setPollingIds(new Set());
- return;
- }
-
- try {
- const res = await fetch(`/api/proxy/knowledge-base/documents`);
-
- if (res.ok) {
- const data = await res.json();
- setDocuments(data.documents || []);
- setUsage(data.usage || null);
-
- // Track processing documents for polling
- const processingIds = new Set(
- data.documents
- .filter((d: Document) => d.status === "processing" || d.status === "uploading")
- .map((d: Document) => d.id)
- );
- setPollingIds(processingIds);
- } else {
- // Stop polling on error response
- setPollingIds(new Set());
- }
- } catch (error) {
- console.error("Failed to fetch documents:", error);
- setPollingIds(new Set());
- } finally {
- setIsLoadingDocs(false);
- }
- }, [userId]);
-
- // Fetch Aurora Learn setting
- const fetchAuroraLearnSetting = useCallback(async () => {
- if (!userId) {
- setIsLoadingLearn(false);
- return;
- }
-
- try {
- const data = await userPreferencesService.getAuroraLearnSetting();
- setAuroraLearnEnabled(data.enabled);
- } catch (error) {
- console.error("Failed to fetch Aurora Learn setting:", error);
- // Default to enabled on error
- setAuroraLearnEnabled(true);
- } finally {
- setIsLoadingLearn(false);
- }
- }, [userId]);
-
- // Handle Aurora Learn toggle
- const handleToggleAuroraLearn = async (enabled: boolean) => {
- if (!userId) return;
-
- setIsTogglingLearn(true);
- try {
- await userPreferencesService.setAuroraLearnSetting(enabled);
- setAuroraLearnEnabled(enabled);
- toast({
- title: enabled ? "Aurora Learn enabled" : "Aurora Learn disabled",
- description: enabled
- ? "Aurora will learn from your feedback to improve future analyses."
- : "Aurora will no longer save or use feedback for learning.",
- });
- } catch (error) {
- toast({
- title: "Failed to update setting",
- description: error instanceof Error ? error.message : "An error occurred",
- variant: "destructive",
- });
- } finally {
- setIsTogglingLearn(false);
- }
- };
-
- // Initial fetch
- useEffect(() => {
- if (userId && !userLoading) {
- fetchMemory();
- fetchDocuments();
- fetchAuroraLearnSetting();
- }
- }, [userId, userLoading, fetchMemory, fetchDocuments, fetchAuroraLearnSetting]);
-
- // Polling for processing documents
- useEffect(() => {
- if (pollingIds.size === 0) return;
-
- const interval = setInterval(() => {
- fetchDocuments();
- }, 3000);
-
- return () => clearInterval(interval);
- }, [pollingIds, fetchDocuments]);
-
- // Save memory
- const handleSaveMemory = async () => {
- if (!userId) return;
-
- setIsSavingMemory(true);
- try {
- const res = await fetch(`/api/proxy/knowledge-base/memory`, {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ content: memoryContent }),
- });
-
- if (res.ok) {
- setOriginalMemory(memoryContent);
- toast({
- title: "Memory saved",
- description: "Your context will be used in all future conversations.",
- });
- } else {
- const data = await res.json();
- throw new Error(data.error || "Failed to save memory");
- }
- } catch (error) {
- toast({
- title: "Failed to save memory",
- description: error instanceof Error ? error.message : "An error occurred",
- variant: "destructive",
- });
- } finally {
- setIsSavingMemory(false);
- }
- };
-
- // Upload document
- const handleUpload = async (event: React.ChangeEvent) => {
- const file = event.target.files?.[0];
- if (!file || !userId) return;
-
- // Validate file type
- const allowedTypes = [".md", ".txt", ".pdf"];
- const dotIndex = file.name.lastIndexOf(".");
- if (dotIndex === -1 || dotIndex === file.name.length - 1) {
- toast({
- title: "Invalid file type",
- description: "Supported formats: Markdown (.md), Plain Text (.txt), PDF (.pdf)",
- variant: "destructive",
- });
- return;
- }
- const ext = file.name.toLowerCase().slice(dotIndex);
- if (!allowedTypes.includes(ext)) {
- toast({
- title: "Invalid file type",
- description: "Supported formats: Markdown (.md), Plain Text (.txt), PDF (.pdf)",
- variant: "destructive",
- });
- return;
- }
-
- // Validate file size (50MB)
- if (file.size > 50 * 1024 * 1024) {
- toast({
- title: "File too large",
- description: "Maximum file size is 50MB",
- variant: "destructive",
- });
- return;
- }
-
- setIsUploading(true);
- const formData = new FormData();
- formData.append("file", file);
-
- try {
- const res = await fetch(`/api/proxy/knowledge-base/upload`, {
- method: "POST",
- body: formData,
- });
-
- if (res.ok) {
- toast({
- title: "Document uploaded",
- description: "Processing in background. This may take a few moments.",
- });
- try {
- await fetchDocuments();
- } catch (error) {
- console.error("Failed to refresh documents list:", error);
- toast({
- title: "List refresh failed",
- description: "Your document was uploaded but the list didn't refresh. Try reloading the page.",
- });
- }
- } else {
- const data = await res.json();
- throw new Error(data.error || "Failed to upload document");
- }
- } catch (error) {
- toast({
- title: "Upload failed",
- description: error instanceof Error ? error.message : "An error occurred",
- variant: "destructive",
- });
- } finally {
- setIsUploading(false);
- if (fileInputRef.current) {
- fileInputRef.current.value = "";
- }
- }
- };
-
- // Delete document
- const handleDelete = async (docId: string, filename: string) => {
- if (!userId) return;
-
- setDeletingId(docId);
- try {
- const res = await fetch(
- `/api/proxy/knowledge-base/documents/${docId}`,
- {
- method: "DELETE",
- }
- );
-
- if (res.ok) {
- toast({
- title: "Document deleted",
- description: `"${filename}" has been removed from your knowledge base.`,
- });
- try {
- await fetchDocuments();
- } catch (error) {
- console.error("Failed to refresh documents list:", error);
- toast({
- title: "List refresh failed",
- description: "The document was deleted but the list didn't refresh. Try reloading the page.",
- });
- }
- } else {
- const data = await res.json();
- throw new Error(data.error || "Failed to delete document");
- }
- } catch (error) {
- toast({
- title: "Delete failed",
- description: error instanceof Error ? error.message : "An error occurred",
- variant: "destructive",
- });
- } finally {
- setDeletingId(null);
- }
- };
-
- function getStatusBadge(status: Document["status"]) {
- const statusConfig = {
- uploading: {
- variant: "secondary" as const,
- className: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
- icon: ,
- label: "Uploading",
- },
- processing: {
- variant: "secondary" as const,
- className: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
- icon: ,
- label: "Processing",
- },
- ready: {
- variant: "secondary" as const,
- className: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200",
- icon: ,
- label: "Ready",
- },
- failed: {
- variant: "destructive" as const,
- className: "",
- icon: ,
- label: "Failed",
- },
- };
-
- const config = statusConfig[status];
- if (!config) return null;
-
- return (
-
- {config.icon}
- {config.label}
-
- );
- }
-
- function formatFileSize(bytes: number): string {
- if (bytes < 1024) return `${bytes} B`;
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
- }
-
- if (userLoading) {
- return (
-
-
-
- );
- }
-
- return (
-
-
-
Knowledge Base
-
- Manage your team's documentation and context for Aurora to reference.
-
-
-
- {!canWrite && (
-
-
- You have read-only access. Contact an admin to get Editor or Admin role to upload documents and modify memory.
-
-
- )}
-
- {/* Aurora Learn Section */}
-
-
-
-
-
- Aurora Learn
-
- {isLoadingLearn ? (
-
- ) : (
-
- )}
-
-
-
-
- Stores feedback locally to improve Aurora for your system
- All data remains on your infrastructure and is never sent externally
-
-
-
-
- {/* Memory Section */}
-
-
-
-
- Memory
-
-
- Context that Aurora always remembers. Add key facts, patterns, and
- conventions your team uses. This is included in every conversation.
-
-
-
- {isLoadingMemory ? (
-
-
-
- ) : (
- <>
-
-
-
- {/* Documents Section */}
-
-
-
-
- Documents
-
-
- Upload runbooks, architecture docs, and postmortems for Aurora to
- search during investigations.
-
- {usage && (
-
- {usage.document_count}/{usage.document_limit} documents · {usage.storage_used_mb}/{usage.storage_limit_mb} MB used
-
- )}
-
-
- {/* Upload Button */}
-
-
-
fileInputRef.current?.click()}
- disabled={isUploading || !canWrite}
- variant="outline"
- >
- {isUploading ? (
- <>
-
- Uploading...
- >
- ) : (
- <>
-
- Upload Document
- >
- )}
-
-
- Supported formats: Markdown (.md), Plain Text (.txt), PDF (.pdf).
- Max size: 50MB.
-
-
-
- {/* Documents List */}
- {isLoadingDocs ? (
-
-
-
- ) : documents.length === 0 ? (
-
-
-
- No documents uploaded yet. Upload your first document to get
- started.
-
-
- ) : (
-
- {documents.map((doc) => (
-
-
-
-
-
{doc.original_filename}
-
- {formatFileSize(doc.file_size_bytes)}
- {doc.status === "ready" && doc.chunk_count > 0 && (
- <>
- -
- {doc.chunk_count} chunks
- >
- )}
- {doc.status === "failed" && doc.error_message && (
- <>
- -
-
- {doc.error_message}
-
- >
- )}
-
-
-
-
- {getStatusBadge(doc.status)}
- handleDelete(doc.id, doc.original_filename)}
- disabled={deletingId === doc.id || !canWrite}
- >
- {deletingId === doc.id ? (
-
- ) : (
-
- )}
-
-
-
- ))}
-
- )}
-
-
-
-
-
- );
-}
diff --git a/client/src/components/MemorySettings.tsx b/client/src/components/MemorySettings.tsx
new file mode 100644
index 000000000..541130b88
--- /dev/null
+++ b/client/src/components/MemorySettings.tsx
@@ -0,0 +1,565 @@
+"use client";
+
+import { useState, useEffect, useCallback, useRef } from "react";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { useToast } from "@/hooks/use-toast";
+import { useUserId } from "@/hooks/use-user-id";
+import {
+ Loader2,
+ Upload,
+ Trash2,
+ FileText,
+ Brain,
+ Plus,
+ BookOpen,
+ Server,
+ Lightbulb,
+ ScrollText,
+ Package,
+} from "lucide-react";
+import { useUser } from "@/hooks/useAuthHooks";
+import { DiscoverySettings } from "@/components/DiscoverySettings";
+import { canWrite as checkCanWrite } from "@/lib/roles";
+
+const MEMORY_CATEGORIES = [
+ "context",
+ "runbook",
+ "infrastructure",
+ "learned",
+ "postmortem",
+ "artifact",
+] as const;
+
+// Categories users can manually create/upload and filter by — excludes artifact (internal system category)
+const USER_WRITABLE_CATEGORIES = ["context", "runbook", "infrastructure", "learned", "postmortem"] as const;
+
+type MemoryCategory = (typeof MEMORY_CATEGORIES)[number];
+
+const CATEGORY_META: Record = {
+ context: { label: "Context", icon: , color: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200" },
+ runbook: { label: "Runbook", icon: , color: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" },
+ infrastructure: { label: "Infrastructure", icon: , color: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" },
+ learned: { label: "Learned", icon: , color: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" },
+ postmortem: { label: "Postmortem", icon: , color: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200" },
+ artifact: { label: "Artifact", icon: , color: "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200" },
+};
+
+interface MemoryEntry {
+ id: string;
+ title: string;
+ category: MemoryCategory;
+ description: string | null;
+ last_edited_by: string | null;
+ updated_at: string | null;
+}
+
+export function MemorySettings() {
+ const { userId, isLoading: userLoading } = useUserId();
+ const { user } = useUser();
+ const canWrite = checkCanWrite(user?.role);
+ const { toast } = useToast();
+
+ // Memory entries state
+ const [entries, setEntries] = useState([]);
+ const [isLoadingEntries, setIsLoadingEntries] = useState(true);
+ const [filterCategory, setFilterCategory] = useState("all");
+ const [deletingId, setDeletingId] = useState(null);
+
+ // Create form state
+ const [showCreateForm, setShowCreateForm] = useState(false);
+ const [newTitle, setNewTitle] = useState("");
+ const [newCategory, setNewCategory] = useState("context");
+ const [newContent, setNewContent] = useState("");
+ const [newDescription, setNewDescription] = useState("");
+ const [isCreating, setIsCreating] = useState(false);
+
+ // Upload state
+ const [isUploading, setIsUploading] = useState(false);
+ const fileInputRef = useRef(null);
+
+ const fetchEntries = useCallback(async () => {
+ if (!userId) {
+ setIsLoadingEntries(false);
+ return;
+ }
+
+ try {
+ const url = filterCategory === "all"
+ ? "/api/proxy/memory/entries"
+ : `/api/proxy/memory/entries?category=${filterCategory}`;
+ const res = await fetch(url);
+
+ if (res.ok) {
+ const data = await res.json();
+ setEntries(data.entries || []);
+ }
+ } catch (error) {
+ console.error("Failed to fetch memory entries:", error);
+ } finally {
+ setIsLoadingEntries(false);
+ }
+ }, [userId, filterCategory]);
+
+ useEffect(() => {
+ if (userId && !userLoading) {
+ fetchEntries();
+ }
+ }, [userId, userLoading, fetchEntries]);
+
+ const handleCreate = async () => {
+ if (!userId) return;
+
+ setIsCreating(true);
+ try {
+ const res = await fetch("/api/proxy/memory/entries", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ category: newCategory,
+ title: newTitle.trim(),
+ content: newContent.trim(),
+ description: newDescription.trim() || undefined,
+ }),
+ });
+
+ if (res.ok) {
+ toast({ title: "Memory entry created" });
+ setNewTitle("");
+ setNewCategory("context");
+ setNewContent("");
+ setNewDescription("");
+ setShowCreateForm(false);
+ await fetchEntries();
+ } else {
+ const text = await res.text();
+ let msg = "Failed to create entry";
+ try { msg = JSON.parse(text).error || msg; } catch {}
+ throw new Error(msg);
+ }
+ } catch (error) {
+ toast({
+ title: "Failed to create",
+ description: error instanceof Error ? error.message : "An error occurred",
+ variant: "destructive",
+ });
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ const handleDelete = async (entryId: string, title: string) => {
+ if (!userId) return;
+
+ setDeletingId(entryId);
+ try {
+ const res = await fetch(`/api/proxy/memory/entries/${entryId}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ const text = await res.text();
+ let msg = "Failed to delete entry";
+ try { msg = JSON.parse(text).error || msg; } catch {}
+ throw new Error(msg);
+ }
+
+ toast({
+ title: "Entry deleted",
+ description: `"${title}" has been removed.`,
+ });
+ await fetchEntries();
+ } catch (error) {
+ toast({
+ title: "Delete failed",
+ description: error instanceof Error ? error.message : "An error occurred",
+ variant: "destructive",
+ });
+ } finally {
+ setDeletingId(null);
+ }
+ };
+
+ const handleUpload = async (event: React.ChangeEvent) => {
+ const files = event.target.files;
+ if (!files || files.length === 0 || !userId) return;
+
+ const allowedTypes = [".md", ".txt", ".pdf"];
+
+ // Validate all files first
+ for (const file of Array.from(files)) {
+ const dotIndex = file.name.lastIndexOf(".");
+ if (dotIndex === -1 || dotIndex === file.name.length - 1) {
+ toast({
+ title: "Invalid file type",
+ description: `"${file.name}" is not supported. Use: .md, .txt, .pdf`,
+ variant: "destructive",
+ });
+ return;
+ }
+ const ext = file.name.toLowerCase().slice(dotIndex);
+ if (!allowedTypes.includes(ext)) {
+ toast({
+ title: "Invalid file type",
+ description: `"${file.name}" is not supported. Use: .md, .txt, .pdf`,
+ variant: "destructive",
+ });
+ return;
+ }
+ if (file.size > 50 * 1024 * 1024) {
+ toast({
+ title: "File too large",
+ description: `"${file.name}" exceeds 50MB limit`,
+ variant: "destructive",
+ });
+ return;
+ }
+ }
+
+ setIsUploading(true);
+ let successCount = 0;
+ let lastError: string | null = null;
+
+ for (const file of Array.from(files)) {
+ const formData = new FormData();
+ formData.append("file", file);
+ formData.append("category", "context");
+
+ try {
+ const res = await fetch("/api/proxy/memory/upload", {
+ method: "POST",
+ body: formData,
+ });
+
+ if (res.ok) {
+ successCount++;
+ } else {
+ const text = await res.text();
+ try { lastError = JSON.parse(text).error || `Failed to upload "${file.name}"`; } catch { lastError = `Failed to upload "${file.name}"`; }
+ }
+ } catch (error) {
+ lastError = error instanceof Error ? error.message : `Failed to upload "${file.name}"`;
+ }
+ }
+
+ if (successCount > 0) {
+ toast({
+ title: successCount === 1 ? "File uploaded" : `${successCount} files uploaded`,
+ description: successCount === 1
+ ? `"${files[0].name}" has been added to memory.`
+ : `${successCount} file(s) have been added to memory.`,
+ });
+ await fetchEntries();
+ }
+ if (lastError && successCount < files.length) {
+ toast({
+ title: "Some uploads failed",
+ description: lastError,
+ variant: "destructive",
+ });
+ }
+
+ setIsUploading(false);
+ if (fileInputRef.current) {
+ fileInputRef.current.value = "";
+ }
+ };
+
+ const formatDate = (dateStr: string | null) => {
+ if (!dateStr) return "";
+ const d = new Date(dateStr);
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
+ };
+
+ const handleCategoryChange = async (entryId: string, newCategory: MemoryCategory) => {
+ try {
+ const res = await fetch(`/api/proxy/memory/entries/${entryId}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ category: newCategory }),
+ });
+ if (res.ok) {
+ setEntries((prev) =>
+ prev.map((e) => e.id === entryId ? { ...e, category: newCategory } : e)
+ );
+ } else {
+ const text = await res.text();
+ let msg = "An error occurred";
+ try { msg = JSON.parse(text).error || msg; } catch {}
+ toast({
+ title: "Failed to update category",
+ description: msg,
+ variant: "destructive",
+ });
+ }
+ } catch {
+ toast({
+ title: "Failed to update category",
+ description: "An error occurred",
+ variant: "destructive",
+ });
+ }
+ };
+
+ if (userLoading) {
+ return (
+
+
+
+ );
+ }
+
+ const filteredEntries = entries;
+
+ return (
+
+
+
Memory
+
+ Manage your team's knowledge — context, runbooks, infrastructure docs, and learnings Aurora references during investigations.
+
+
+
+ {!canWrite && (
+
+
+ You have read-only access. Contact an admin to get Editor or Admin role to manage memory.
+
+
+ )}
+
+ {/* Memory Entries Section */}
+
+
+
+
+
+ Memory Entries
+
+
+
setFilterCategory(v)}>
+
+
+
+
+ All categories
+ {USER_WRITABLE_CATEGORIES.map((cat) => (
+
+ {CATEGORY_META[cat].label}
+
+ ))}
+
+
+ {canWrite && (
+ <>
+
+
fileInputRef.current?.click()}
+ disabled={isUploading}
+ >
+ {isUploading ? (
+
+ ) : (
+
+ )}
+ {isUploading ? "Uploading..." : "Upload"}
+
+
setShowCreateForm(!showCreateForm)}>
+
+ New
+
+ >
+ )}
+
+
+
+ All knowledge Aurora accumulates — manually added context, uploaded runbooks, discovered infrastructure, and learned patterns.
+
+
+
+ {/* Create Form */}
+ {showCreateForm && canWrite && (
+
+
+
+ Title
+ setNewTitle(e.target.value)}
+ placeholder="e.g. Production Runbook"
+ />
+
+
+ Category
+ setNewCategory(v as MemoryCategory)}>
+
+
+
+
+ {USER_WRITABLE_CATEGORIES.map((cat) => (
+
+ {CATEGORY_META[cat].label}
+
+ ))}
+
+
+
+
+
+ Description (optional)
+ setNewDescription(e.target.value)}
+ placeholder="Brief summary of what this contains"
+ />
+
+
+ Content (Markdown)
+
+
+ setShowCreateForm(false)}>
+ Cancel
+
+
+ {isCreating ? (
+ <>
+
+ Creating...
+ >
+ ) : (
+ "Create Entry"
+ )}
+
+
+
+ )}
+
+
+ {/* Entry List */}
+ {isLoadingEntries ? (
+
+
+
+ ) : filteredEntries.length === 0 ? (
+
+
+
+ {filterCategory === "all"
+ ? "No memory entries yet. Create one or upload a file to get started."
+ : `No entries in the "${CATEGORY_META[filterCategory as MemoryCategory]?.label}" category.`}
+
+
+ ) : (
+
+ {filteredEntries.map((entry) => {
+ const meta = CATEGORY_META[entry.category] || CATEGORY_META.artifact;
+ return (
+
+
+
+ {meta.icon}
+
+
+
{entry.title}
+
+ {canWrite ? (
+ handleCategoryChange(entry.id, v as MemoryCategory)}
+ >
+
+
+
+
+ {USER_WRITABLE_CATEGORIES.map((cat) => (
+
+ {CATEGORY_META[cat].label}
+
+ ))}
+
+
+ ) : (
+
+ {meta.label}
+
+ )}
+ {entry.description && (
+ {entry.description}
+ )}
+ {entry.updated_at && (
+ {formatDate(entry.updated_at)}
+ )}
+ {entry.last_edited_by && (
+ by {entry.last_edited_by}
+ )}
+
+
+
+
+ {canWrite && (
+ handleDelete(entry.id, entry.title)}
+ disabled={deletingId === entry.id}
+ >
+ {deletingId === entry.id ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/ProfileSettings.tsx b/client/src/components/ProfileSettings.tsx
index 95758b595..3dd545e22 100644
--- a/client/src/components/ProfileSettings.tsx
+++ b/client/src/components/ProfileSettings.tsx
@@ -21,7 +21,7 @@ const ROLE_INFO = {
label: "Editor",
color: "text-amber-500",
badgeClass: "bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-200",
- description: "Everything a Viewer can do, plus connect integrations, manage documents and knowledge base, edit incidents and postmortems, and manage SSH keys and VMs.",
+ description: "Everything a Viewer can do, plus connect integrations, manage memory and documents, edit incidents and postmortems, and manage SSH keys and VMs.",
},
admin: {
icon: Crown,
diff --git a/client/src/components/SettingsModal.tsx b/client/src/components/SettingsModal.tsx
index 92d5ce98a..dea4ff4e7 100644
--- a/client/src/components/SettingsModal.tsx
+++ b/client/src/components/SettingsModal.tsx
@@ -3,12 +3,11 @@
import React, { useState, useMemo, useEffect } from 'react';
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle, DialogDescription } from "@/components/ui/dialog";
-import { Settings, User, BookOpen, FileText, Building2, Shield, DollarSign } from "lucide-react";
+import { Settings, User, BookOpen, Building2, Shield, DollarSign } from "lucide-react";
import { cn } from "@/lib/utils";
import { GeneralSettings } from "@/components/GeneralSettings";
import { ProfileSettings } from "@/components/ProfileSettings";
-import { KnowledgeBaseSettings } from "@/components/KnowledgeBaseSettings";
-import { PostmortemsSettings } from "@/components/PostmortemsSettings";
+import { MemorySettings } from "@/components/MemorySettings";
import { OrgSettings } from "@/components/OrgSettings";
import { SecuritySettings } from "@/components/SecuritySettings";
import { useUser, useAuth } from "@/hooks/useAuthHooks";
@@ -21,7 +20,7 @@ interface SettingsModalProps {
onClose: () => void;
}
-type SettingsTab = 'organization' | 'general' | 'profile' | 'knowledge-base' | 'postmortems' | 'security' | 'usage';
+type SettingsTab = 'organization' | 'general' | 'profile' | 'memory' | 'security' | 'usage';
const UsageTab = React.lazy(() => import('@/app/monitor/components/usage-tab'));
@@ -51,16 +50,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
description: 'Update your profile information'
},
{
- id: 'knowledge-base' as SettingsTab,
- label: 'Knowledge Base',
+ id: 'memory' as SettingsTab,
+ label: 'Memory',
icon: BookOpen,
- description: 'Manage documentation and context'
- },
- {
- id: 'postmortems' as SettingsTab,
- label: 'Postmortems',
- icon: FileText,
- description: 'View generated postmortems'
+ description: 'Manage knowledge and context'
},
{
id: 'security' as SettingsTab,
@@ -110,11 +103,11 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
);
- case 'knowledge-base':
+ case 'memory':
return (
);
@@ -129,13 +122,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
);
- case 'postmortems':
- return (
-
- );
-
case 'security':
return (
diff --git a/client/src/components/tool-calls/CommandLogo.tsx b/client/src/components/tool-calls/CommandLogo.tsx
index efe05c539..10de36877 100644
--- a/client/src/components/tool-calls/CommandLogo.tsx
+++ b/client/src/components/tool-calls/CommandLogo.tsx
@@ -412,8 +412,9 @@ const getLogoForCommand = (command: string | any, toolName: string, provider?: s
return 'web'
}
- // Knowledge base tool
- if (tool === 'knowledge_base_search') {
+ // Memory tools
+ if (tool === 'list_memories' || tool === 'read_memory' || tool === 'write_memory'
+ || tool === 'append_to_memory' || tool === 'edit_memory' || tool === 'grep_memories') {
return 'knowledgeBase'
}
diff --git a/client/src/components/tool-calls/ToolExecutionWidget.tsx b/client/src/components/tool-calls/ToolExecutionWidget.tsx
index 407607c58..2b1cf162e 100644
--- a/client/src/components/tool-calls/ToolExecutionWidget.tsx
+++ b/client/src/components/tool-calls/ToolExecutionWidget.tsx
@@ -81,8 +81,18 @@ const ToolExecutionWidget = ({ tool, className, sendMessage, sendRaw, onToolUpda
let command: string = tool.command || normalizedInput || defaultCliCommand
// Special display names for specific tools
- if (tool.tool_name === "knowledge_base_search") {
- command = "Knowledge Base"
+ if (tool.tool_name === "list_memories") {
+ command = "List Memories"
+ } else if (tool.tool_name === "read_memory") {
+ command = "Read Memory"
+ } else if (tool.tool_name === "write_memory") {
+ command = "Write Memory"
+ } else if (tool.tool_name === "append_to_memory") {
+ command = "Append to Memory"
+ } else if (tool.tool_name === "edit_memory") {
+ command = "Edit Memory"
+ } else if (tool.tool_name === "grep_memories") {
+ command = "Search Memory"
}
if (tool.tool_name === "load_skill") {
diff --git a/client/src/lib/feedback-storage.ts b/client/src/lib/feedback-storage.ts
deleted file mode 100644
index 55f8acfc2..000000000
--- a/client/src/lib/feedback-storage.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { writeFile, readFile, mkdir } from 'fs/promises';
-import { existsSync } from 'fs';
-import path from 'path';
-
-export interface StoredFeedback {
- id: string;
- description: string;
- userAgent: string;
- url: string;
- userId: string;
- userEmail: string;
- timestamp: string;
- images: { name: string; size: number; type: string; filename: string }[];
- status: "new" | "in-progress" | "resolved" | "closed";
-}
-
-// File system paths for feedback storage
-const FEEDBACK_DIR = path.join(process.cwd(), 'feedback-data');
-const FEEDBACK_FILE = path.join(FEEDBACK_DIR, 'feedback.json');
-const IMAGES_DIR = path.join(FEEDBACK_DIR, 'images');
-
-export async function ensureFeedbackDir() {
- if (!existsSync(FEEDBACK_DIR)) await mkdir(FEEDBACK_DIR, { recursive: true });
- if (!existsSync(IMAGES_DIR)) await mkdir(IMAGES_DIR, { recursive: true });
-}
-
-
-export async function readFeedback(): Promise
{
- try {
- await ensureFeedbackDir();
- if (!existsSync(FEEDBACK_FILE)) return [];
- const data = await readFile(FEEDBACK_FILE, 'utf-8');
- return JSON.parse(data);
- } catch (error) {
- console.error('Error reading feedback:', error);
- return [];
- }
-}
-
-
-export async function writeFeedback(feedback: StoredFeedback[]) {
- try {
- await ensureFeedbackDir();
- await writeFile(FEEDBACK_FILE, JSON.stringify(feedback, null, 2));
- } catch (error) {
- console.error('Error writing feedback:', error);
- }
-}
-
-
-export async function saveImage(file: File, feedbackId: string): Promise<{ name: string; size: number; type: string; filename: string }> {
- const arrayBuffer = await file.arrayBuffer();
- const buffer = Buffer.from(arrayBuffer);
-
- // Generate unique filename to prevent conflicts
- const fileExtension = file.name.split('.').pop() || 'jpg';
- const filename = `${feedbackId}_${Date.now()}_${Math.random().toString(36).substr(2, 6)}.${fileExtension}`;
- const filepath = path.join(IMAGES_DIR, filename);
-
- // Save the image file to disk
- await writeFile(filepath, buffer);
-
- return {
- name: file.name, // Original filename from user
- size: file.size, // File size in bytes
- type: file.type, // MIME type (e.g., 'image/jpeg')
- filename: filename, // Generated unique filename on disk
- };
-}
-
-
-export function getImagePath(filename: string): string {
- return path.join(IMAGES_DIR, filename);
-}
\ No newline at end of file
diff --git a/client/src/lib/services/incident-feedback.ts b/client/src/lib/services/incident-feedback.ts
deleted file mode 100644
index 87332141d..000000000
--- a/client/src/lib/services/incident-feedback.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-// ============================================================================
-// Aurora Learn Service
-// - Incident feedback (thumbs up/down)
-// - Aurora Learn settings (enable/disable)
-// ============================================================================
-
-import { apiGet, apiPost, apiPut } from './api-client';
-
-// ============================================================================
-// Types
-// ============================================================================
-
-export type FeedbackType = 'helpful' | 'not_helpful';
-
-export interface IncidentFeedback {
- id: string;
- feedbackType: FeedbackType;
- comment?: string;
- createdAt: string;
-}
-
-export interface SubmitFeedbackResponse {
- success: boolean;
- feedbackId: string;
- feedbackType: FeedbackType;
- storedForLearning: boolean;
- createdAt: string;
-}
-
-export interface AuroraLearnSetting {
- enabled: boolean;
-}
-
-interface GetFeedbackResponse {
- feedback: IncidentFeedback | null;
-}
-
-interface SetAuroraLearnResponse {
- success: boolean;
- enabled: boolean;
-}
-
-// ============================================================================
-// Incident Feedback
-// ============================================================================
-
-/**
- * Submit feedback for an incident (thumbs up/down).
- */
-export async function submitFeedback(
- incidentId: string,
- feedbackType: FeedbackType,
- comment?: string
-): Promise {
- return apiPost(
- `/api/incidents/${incidentId}/feedback`,
- { feedback_type: feedbackType, comment: comment || undefined }
- );
-}
-
-/**
- * Get existing feedback for an incident.
- */
-export async function getFeedback(incidentId: string): Promise {
- const data = await apiGet(`/api/incidents/${incidentId}/feedback`);
- return data.feedback;
-}
-
-// ============================================================================
-// Aurora Learn Settings
-// ============================================================================
-
-/**
- * Get the Aurora Learn setting for the current user.
- * Defaults to true if not set.
- */
-export async function getAuroraLearnSetting(): Promise {
- return apiGet('/api/user/preferences/aurora-learn');
-}
-
-/**
- * Set the Aurora Learn setting for the current user.
- */
-export async function setAuroraLearnSetting(
- enabled: boolean
-): Promise {
- return apiPut('/api/user/preferences/aurora-learn', { enabled });
-}
-
-// ============================================================================
-// Service Objects (for convenience imports)
-// ============================================================================
-
-export const incidentFeedbackService = {
- submitFeedback,
- getFeedback,
-};
-
-export const userPreferencesService = {
- getAuroraLearnSetting,
- setAuroraLearnSetting,
-};
diff --git a/deploy/helm/aurora/templates/configmap.yaml b/deploy/helm/aurora/templates/configmap.yaml
index e79833e35..2052542b8 100644
--- a/deploy/helm/aurora/templates/configmap.yaml
+++ b/deploy/helm/aurora/templates/configmap.yaml
@@ -8,7 +8,6 @@ data:
# Auto-generated service URLs (computed from release name if empty)
POSTGRES_HOST: {{ default (printf "%s-postgres" (include "aurora.fullname" .)) .Values.config.POSTGRES_HOST | quote }}
REDIS_URL: {{ default (printf "redis://%s-redis:6379/0" (include "aurora.fullname" .)) .Values.config.REDIS_URL | quote }}
- WEAVIATE_HOST: {{ default (printf "%s-weaviate" (include "aurora.fullname" .)) .Values.config.WEAVIATE_HOST | quote }}
MEMGRAPH_HOST: {{ default (printf "%s-memgraph" (include "aurora.fullname" .)) .Values.config.MEMGRAPH_HOST | quote }}
BACKEND_URL: {{ default (printf "http://%s-server:5080" (include "aurora.fullname" .)) .Values.config.BACKEND_URL | quote }}
CHATBOT_INTERNAL_URL: {{ default (printf "http://%s-chatbot:5007" (include "aurora.fullname" .)) .Values.config.CHATBOT_INTERNAL_URL | quote }}
@@ -32,7 +31,7 @@ data:
# NOTE: NEXT_PUBLIC_* vars are included because the backend reads feature flags like
# NEXT_PUBLIC_ENABLE_OVH, NEXT_PUBLIC_ENABLE_SHAREPOINT, etc. to conditionally register routes.
{{- range $key, $value := .Values.config }}
- {{- if not (has $key (list "POSTGRES_HOST" "REDIS_URL" "WEAVIATE_HOST" "MEMGRAPH_HOST" "BACKEND_URL" "CHATBOT_INTERNAL_URL" "VAULT_ADDR" "SEARXNG_URL" "NEXT_PUBLIC_BACKEND_URL" "PUBLIC_API_URL" "PUBLIC_WS_URL" "AUTH_URL" (ternary "STORAGE_ENDPOINT_URL" "" (and $.Values.services.minio.enabled (not $.Values.config.STORAGE_ENDPOINT_URL))))) }}
+ {{- if not (has $key (list "POSTGRES_HOST" "REDIS_URL" "MEMGRAPH_HOST" "BACKEND_URL" "CHATBOT_INTERNAL_URL" "VAULT_ADDR" "SEARXNG_URL" "NEXT_PUBLIC_BACKEND_URL" "PUBLIC_API_URL" "PUBLIC_WS_URL" "AUTH_URL" (ternary "STORAGE_ENDPOINT_URL" "" (and $.Values.services.minio.enabled (not $.Values.config.STORAGE_ENDPOINT_URL))))) }}
{{ $key }}: {{ $value | quote }}
{{- end }}
{{- end }}
diff --git a/deploy/helm/aurora/templates/pod-isolation.yaml b/deploy/helm/aurora/templates/pod-isolation.yaml
index 082371d8f..5a628d36f 100644
--- a/deploy/helm/aurora/templates/pod-isolation.yaml
+++ b/deploy/helm/aurora/templates/pod-isolation.yaml
@@ -20,7 +20,7 @@ metadata:
# ------------------------------------------------------------------------------
# Network Isolation - Default Deny for Terminal Pods
# ------------------------------------------------------------------------------
-# Blocks terminal pods from accessing cluster-internal services (Vault, Weaviate,
+# Blocks terminal pods from accessing cluster-internal services (Vault,
# Postgres, Redis, etc.) while allowing external internet access for cloud CLI
# operations (AWS, GCP, Azure, etc.).
#
diff --git a/deploy/helm/aurora/templates/t2v-transformers-deployment.yaml b/deploy/helm/aurora/templates/t2v-transformers-deployment.yaml
deleted file mode 100644
index 1fa58b361..000000000
--- a/deploy/helm/aurora/templates/t2v-transformers-deployment.yaml
+++ /dev/null
@@ -1,59 +0,0 @@
-apiVersion: apps/v1
-kind: Deployment
-metadata:
- name: {{ include "aurora.fullname" . }}-t2v-transformers
- labels:
- {{- include "aurora.labels" . | nindent 4 }}
-spec:
- replicas: {{ .Values.replicaCounts.transformers }}
- selector:
- matchLabels:
- app.kubernetes.io/name: {{ include "aurora.name" . }}-t2v-transformers
- app.kubernetes.io/instance: {{ .Release.Name }}
- template:
- metadata:
- labels:
- app.kubernetes.io/name: {{ include "aurora.name" . }}-t2v-transformers
- app.kubernetes.io/instance: {{ .Release.Name }}
- app.kubernetes.io/part-of: aurora
- spec:
- automountServiceAccountToken: false
- securityContext:
- {{- include "aurora.podSecurityContext" (dict "service" "transformers" "global" $ "defaults" (dict "runAsUser" 1000 "runAsGroup" 1000 "fsGroup" 1000)) | nindent 8 }}
- {{- include "aurora.scheduling" (dict "service" "transformers" "global" $) | nindent 6 }}
- containers:
- - name: transformers
- image: cr.weaviate.io/semitechnologies/transformers-inference:sentence-transformers-all-MiniLM-L6-v2
- imagePullPolicy: IfNotPresent
- securityContext:
- {{- include "aurora.containerSecurityContext" (dict "service" "transformers" "global" $ "defaults" (dict)) | nindent 12 }}
- ports:
- - name: http
- containerPort: 8080
- startupProbe:
- httpGet:
- path: /.well-known/ready
- port: 8080
- initialDelaySeconds: 10
- periodSeconds: 10
- timeoutSeconds: 5
- failureThreshold: 30
- readinessProbe:
- httpGet:
- path: /.well-known/ready
- port: 8080
- periodSeconds: 10
- timeoutSeconds: 5
- failureThreshold: 3
- livenessProbe:
- httpGet:
- path: /.well-known/live
- port: 8080
- periodSeconds: 30
- timeoutSeconds: 5
- failureThreshold: 3
- env:
- - name: ENABLE_CUDA
- value: "0"
- resources:
- {{- toYaml .Values.resources.transformers | nindent 12 }}
diff --git a/deploy/helm/aurora/templates/t2v-transformers-service.yaml b/deploy/helm/aurora/templates/t2v-transformers-service.yaml
deleted file mode 100644
index 044b7e0c7..000000000
--- a/deploy/helm/aurora/templates/t2v-transformers-service.yaml
+++ /dev/null
@@ -1,15 +0,0 @@
-apiVersion: v1
-kind: Service
-metadata:
- name: {{ include "aurora.fullname" . }}-t2v-transformers
- labels:
- {{- include "aurora.labels" . | nindent 4 }}
-spec:
- type: ClusterIP
- selector:
- app.kubernetes.io/name: {{ include "aurora.name" . }}-t2v-transformers
- app.kubernetes.io/instance: {{ .Release.Name }}
- ports:
- - name: http
- port: 8080
- targetPort: 8080
diff --git a/deploy/helm/aurora/templates/weaviate-service.yaml b/deploy/helm/aurora/templates/weaviate-service.yaml
deleted file mode 100644
index 4a31fab75..000000000
--- a/deploy/helm/aurora/templates/weaviate-service.yaml
+++ /dev/null
@@ -1,20 +0,0 @@
-{{- if .Values.services.weaviate.enabled }}
-apiVersion: v1
-kind: Service
-metadata:
- name: {{ include "aurora.fullname" . }}-weaviate
- labels:
- {{- include "aurora.labels" . | nindent 4 }}
-spec:
- type: ClusterIP
- selector:
- app.kubernetes.io/name: {{ include "aurora.name" . }}-weaviate
- app.kubernetes.io/instance: {{ .Release.Name }}
- ports:
- - name: http
- port: 8080
- targetPort: 8080
- - name: grpc
- port: 50051
- targetPort: 50051
-{{- end }}
diff --git a/deploy/helm/aurora/templates/weaviate-statefulset.yaml b/deploy/helm/aurora/templates/weaviate-statefulset.yaml
deleted file mode 100644
index d5f40068c..000000000
--- a/deploy/helm/aurora/templates/weaviate-statefulset.yaml
+++ /dev/null
@@ -1,87 +0,0 @@
-{{- if .Values.services.weaviate.enabled }}
-apiVersion: apps/v1
-kind: StatefulSet
-metadata:
- name: {{ include "aurora.fullname" . }}-weaviate
- labels:
- {{- include "aurora.labels" . | nindent 4 }}
-spec:
- serviceName: {{ include "aurora.fullname" . }}-weaviate
- replicas: {{ .Values.replicaCounts.weaviate }}
- selector:
- matchLabels:
- app.kubernetes.io/name: {{ include "aurora.name" . }}-weaviate
- app.kubernetes.io/instance: {{ .Release.Name }}
- template:
- metadata:
- labels:
- app.kubernetes.io/name: {{ include "aurora.name" . }}-weaviate
- app.kubernetes.io/instance: {{ .Release.Name }}
- app.kubernetes.io/part-of: aurora
- spec:
- automountServiceAccountToken: false
- securityContext:
- {{- include "aurora.podSecurityContext" (dict "service" "weaviate" "global" $ "defaults" (dict "runAsUser" 1000 "runAsGroup" 1000 "fsGroup" 1000)) | nindent 8 }}
- {{- include "aurora.scheduling" (dict "service" "weaviate" "global" $) | nindent 6 }}
- containers:
- - name: weaviate
- image: cr.weaviate.io/semitechnologies/weaviate:1.27.6
- imagePullPolicy: IfNotPresent
- securityContext:
- {{- include "aurora.containerSecurityContext" (dict "service" "weaviate" "global" $ "defaults" (dict)) | nindent 12 }}
- ports:
- - name: http
- containerPort: 8080
- - name: grpc
- containerPort: 50051
- env:
- - name: AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED
- value: "true"
- - name: PERSISTENCE_DATA_PATH
- value: "/var/lib/weaviate"
- - name: ENABLE_API_BASED_MODULES
- value: "true"
- - name: CLUSTER_HOSTNAME
- valueFrom:
- fieldRef:
- fieldPath: metadata.name
- - name: ENABLE_MODULES
- value: "text2vec-transformers"
- - name: TRANSFORMERS_INFERENCE_API
- value: "http://{{ include "aurora.fullname" . }}-t2v-transformers:8080"
- volumeMounts:
- - name: weaviate-data
- mountPath: /var/lib/weaviate
- startupProbe:
- httpGet:
- path: /v1/.well-known/ready
- port: 8080
- initialDelaySeconds: 5
- periodSeconds: 10
- timeoutSeconds: 5
- failureThreshold: 30
- readinessProbe:
- httpGet:
- path: /v1/.well-known/ready
- port: 8080
- periodSeconds: 10
- timeoutSeconds: 5
- failureThreshold: 3
- livenessProbe:
- httpGet:
- path: /v1/.well-known/live
- port: 8080
- periodSeconds: 20
- timeoutSeconds: 5
- failureThreshold: 3
- resources:
- {{- toYaml .Values.resources.weaviate | nindent 12 }}
- volumeClaimTemplates:
- - metadata:
- name: weaviate-data
- spec:
- accessModes: ["ReadWriteOnce"]
- resources:
- requests:
- storage: {{ .Values.persistence.weaviate.size }}
-{{- end }}
diff --git a/deploy/helm/aurora/values.yaml b/deploy/helm/aurora/values.yaml
index 054ed8852..cd8008689 100644
--- a/deploy/helm/aurora/values.yaml
+++ b/deploy/helm/aurora/values.yaml
@@ -24,8 +24,6 @@ services:
enabled: true # Set false to use external Postgres (RDS, Cloud SQL, etc.)
redis:
enabled: true # Set false to use external Redis (ElastiCache, Memorystore, etc.)
- weaviate:
- enabled: true # Set false to use Weaviate Cloud or external instance
vault:
enabled: true # Set false to use external Vault or other secrets manager
memgraph:
@@ -137,12 +135,10 @@ replicaCounts:
chatbot: 1 # WebSocket chatbot - stateless, can scale (session affinity enabled by default)
frontend: 1 # Next.js frontend - stateless, can scale
searxng: 1 # Web search engine - stateless, can scale
- transformers: 1 # ML model service - stateless, can scale
# Single-instance services (do NOT increase without additional config)
celeryBeat: 1 # Task scheduler - only 1 to avoid duplicate tasks
redis: 1 # Redis - single instance, no clustering configured
- weaviate: 1 # Vector database - single instance, clustering requires additional config
memgraph: 1 # Graph database - single instance, in-memory
vault: 1 # Secrets manager - single instance, HA requires additional config
postgres: 1 # Database - single instance, replication requires additional config
@@ -172,11 +168,6 @@ config:
STORAGE_CACHE_ENABLED: "true"
STORAGE_CACHE_TTL: "60"
- # --- Vector Database (Weaviate) ---
- WEAVIATE_HOST: "" # Auto-generated if empty: -weaviate
- WEAVIATE_PORT: "8080"
- WEAVIATE_GRPC_PORT: "50051"
-
# --- Graph Database (Memgraph) ---
MEMGRAPH_HOST: "" # Auto-generated if empty: -memgraph
MEMGRAPH_PORT: "7687"
@@ -190,8 +181,6 @@ config:
# Redis: use rediss:// in REDIS_URL to enable TLS
REDIS_SSL_CA_CERTS: "" # Path to CA cert for Redis TLS
REDIS_SSL_CERT_REQS: "" # "none", "optional", "required"
- # Weaviate: set to "true" for HTTPS+gRPC-TLS
- WEAVIATE_SECURE: "false"
# --- Public URLs (REQUIRED) ---
# NEXT_PUBLIC_* vars are written to window.__ENV by docker-entrypoint.sh at startup.
@@ -250,6 +239,15 @@ config:
RCA_OPTIMIZE_COSTS: "false"
GEMINI_DISABLE_THINKING: "false"
+ # --- Embedding model for alert correlation (optional) ---
+ # Improves alert-to-incident grouping with semantic similarity.
+ # Requires a DIRECT API key — OpenRouter does NOT support embeddings.
+ # If empty, uses Jaccard token similarity (works fine for most cases).
+ # Format: provider/model-name
+ # Examples: openai/text-embedding-3-small, google/text-embedding-004,
+ # bedrock/amazon.titan-embed-text-v2:0
+ EMBEDDING_MODEL: ""
+
# --- Multi-agent RCA orchestrator (opt-in) ---
# When "false" (default), background RCA uses the legacy single-agent path.
# When "true", a lead orchestrator triages each RCA and may fan out parallel
@@ -290,7 +288,7 @@ config:
# - Terminal namespace (TERMINAL_NAMESPACE)
# - RBAC Role/RoleBindings for server and chatbot to manage terminal pods
# - NetworkPolicy blocking terminal pods from accessing cluster services
- # (Vault, Weaviate, Postgres, Redis, etc.) while allowing external internet
+ # (Vault, Postgres, Redis, etc.) while allowing external internet
# - K8s API tokens mounted on server/chatbot pods
# Optional additional hardening (manual):
# - Create RuntimeClass for gVisor/Kata sandboxing (set TERMINAL_RUNTIME_CLASS)
@@ -570,15 +568,6 @@ resources:
cpu: "300m"
memory: "512Mi"
ephemeral-storage: "1Gi"
- weaviate:
- requests:
- cpu: "100m"
- memory: "512Mi"
- ephemeral-storage: "100Mi"
- limits:
- cpu: "500m"
- memory: "1Gi"
- ephemeral-storage: "1Gi"
memgraph:
requests:
cpu: "100m"
@@ -588,13 +577,6 @@ resources:
cpu: "500m"
memory: "1Gi"
ephemeral-storage: "500Mi"
- transformers:
- requests:
- cpu: "100m"
- memory: "512Mi"
- limits:
- cpu: "500m"
- memory: "2Gi"
vault:
requests:
cpu: "50m"
@@ -679,10 +661,6 @@ affinity: {}
# tolerations: []
# nodeSelector: {}
# affinity: {}
-# transformers:
-# tolerations: []
-# nodeSelector: {}
-# affinity: {}
# postgres:
# tolerations: []
# nodeSelector: {}
@@ -691,10 +669,6 @@ affinity: {}
# tolerations: []
# nodeSelector: {}
# affinity: {}
-# weaviate:
-# tolerations: []
-# nodeSelector: {}
-# affinity: {}
# vault:
# tolerations: []
# nodeSelector: {}
@@ -710,8 +684,6 @@ affinity: {}
persistence:
postgres:
size: 50Gi # Database storage
- weaviate:
- size: 50Gi # Vector embeddings storage
vault:
size: 10Gi # Secrets storage
redis:
@@ -839,7 +811,7 @@ ingress:
# MITIGATIONS IN PLACE:
# 1. Terminal pods run in an isolated namespace with a default-deny NetworkPolicy
# that blocks all egress to cluster-internal IPs (see pod-isolation.yaml).
-# Untrusted pods cannot reach Vault, Weaviate, Postgres, Redis, or any other internal services.
+# Untrusted pods cannot reach Vault, Postgres, Redis, or any other internal services.
# 2. Terminal pods never receive Vault tokens - credentials are fetched by
# trusted pods and injected per-command via environment variables.
#
diff --git a/docker-compose.airtight.yml b/docker-compose.airtight.yml
index 6bb6dd591..cb717e5b8 100644
--- a/docker-compose.airtight.yml
+++ b/docker-compose.airtight.yml
@@ -40,17 +40,11 @@ x-common-env: &common-env
# Terraform
TF_CLI_CONFIG_FILE: /etc/terraform-airgapped.rc
- # Weaviate
- WEAVIATE_HOST: ${WEAVIATE_HOST}
- WEAVIATE_PORT: ${WEAVIATE_PORT}
- WEAVIATE_GRPC_PORT: ${WEAVIATE_GRPC_PORT}
-
# SSL/TLS for external services
POSTGRES_SSLMODE: ${POSTGRES_SSLMODE:-prefer}
POSTGRES_SSLROOTCERT: ${POSTGRES_SSLROOTCERT:-}
REDIS_SSL_CA_CERTS: ${REDIS_SSL_CA_CERTS:-}
REDIS_SSL_CERT_REQS: ${REDIS_SSL_CERT_REQS:-}
- WEAVIATE_SECURE: ${WEAVIATE_SECURE:-false}
SPLUNK_SSL_VERIFY: ${SPLUNK_SSL_VERIFY:-false}
# AWS base credentials for role assumption
@@ -115,6 +109,7 @@ x-common-env: &common-env
VISION_MODEL: ${VISION_MODEL}
SUMMARIZATION_MODEL: ${SUMMARIZATION_MODEL}
AGENT_RECURSION_LIMIT: ${AGENT_RECURSION_LIMIT}
+ EMBEDDING_MODEL: ${EMBEDDING_MODEL}
# AI Safety Guardrails
GUARDRAILS_ENABLED: ${GUARDRAILS_ENABLED:-true}
@@ -234,8 +229,6 @@ services:
depends_on:
postgres:
condition: service_healthy
- weaviate:
- condition: service_healthy
redis:
condition: service_started
vault:
@@ -405,8 +398,6 @@ services:
TERMINAL_IMAGE: ${TERMINAL_IMAGE}
TERMINAL_POD_TTL: ${TERMINAL_POD_TTL}
depends_on:
- weaviate:
- condition: service_healthy
postgres:
condition: service_healthy
vault:
@@ -433,47 +424,6 @@ services:
retries: 5
restart: unless-stopped
- weaviate:
- image: cr.weaviate.io/semitechnologies/weaviate:1.27.6
- pull_policy: never
- container_name: weaviate
- logging: *default-logging
- depends_on:
- postgres:
- condition: service_healthy
- command:
- - --host
- - 0.0.0.0
- - --port
- - "8080"
- - --scheme
- - http
- ports:
- - "8080:8080"
- - "50051:50051"
- volumes:
- - weaviate_data:/var/lib/weaviate
- restart: unless-stopped
- environment:
- AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: "true"
- PERSISTENCE_DATA_PATH: "/var/lib/weaviate"
- ENABLE_API_BASED_MODULES: "true"
- CLUSTER_HOSTNAME: "node1"
- ENABLE_MODULES: "text2vec-transformers"
- TRANSFORMERS_INFERENCE_API: "http://t2v-transformers:8080"
- healthcheck:
- test:
- [
- "CMD",
- "wget",
- "-q",
- "--spider",
- "http://localhost:8080/v1/.well-known/ready",
- ]
- interval: 10s
- timeout: 5s
- retries: 3
-
aurora-mcp:
image: aurora_mcp:latest
pull_policy: never
@@ -501,14 +451,6 @@ services:
retries: 3
start_period: 10s
- t2v-transformers:
- image: cr.weaviate.io/semitechnologies/transformers-inference:sentence-transformers-all-MiniLM-L6-v2
- pull_policy: never
- logging: *default-logging
- environment:
- ENABLE_CUDA: '0'
- restart: unless-stopped
-
frontend:
image: aurora_frontend:latest
pull_policy: never
@@ -686,7 +628,6 @@ services:
volumes:
postgres-data:
- weaviate_data:
terraform-workdir:
seaweedfs_master_data:
seaweedfs_volume_data:
diff --git a/docker-compose.prod-local.yml b/docker-compose.prod-local.yml
index 2534ee8be..874a5c26d 100644
--- a/docker-compose.prod-local.yml
+++ b/docker-compose.prod-local.yml
@@ -35,22 +35,13 @@ x-common-env: &common-env
# Terraform
TF_CLI_CONFIG_FILE: /etc/terraform.rc
- # Weaviate
- WEAVIATE_HOST: ${WEAVIATE_HOST}
- WEAVIATE_PORT: ${WEAVIATE_PORT}
- WEAVIATE_GRPC_PORT: ${WEAVIATE_GRPC_PORT}
-
# SSL/TLS for external services
POSTGRES_SSLMODE: ${POSTGRES_SSLMODE:-prefer}
POSTGRES_SSLROOTCERT: ${POSTGRES_SSLROOTCERT:-}
REDIS_SSL_CA_CERTS: ${REDIS_SSL_CA_CERTS:-}
REDIS_SSL_CERT_REQS: ${REDIS_SSL_CERT_REQS:-}
- WEAVIATE_SECURE: ${WEAVIATE_SECURE:-false}
SPLUNK_SSL_VERIFY: ${SPLUNK_SSL_VERIFY:-false}
- # Knowledge Base storage uses the pluggable storage system (SeaWeedFS/S3-compatible)
- # Configure via STORAGE_* environment variables
-
# AWS base credentials for role assumption
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
@@ -109,6 +100,7 @@ x-common-env: &common-env
SUMMARIZATION_MODEL: ${SUMMARIZATION_MODEL}
AGENT_RECURSION_LIMIT: ${AGENT_RECURSION_LIMIT}
GEMINI_DISABLE_THINKING: ${GEMINI_DISABLE_THINKING}
+ EMBEDDING_MODEL: ${EMBEDDING_MODEL}
# GitHub auth — every Aurora service that talks to GitHub (server,
# celery_worker, chatbot) needs the auth mode + App credentials to mint
@@ -267,8 +259,6 @@ services:
depends_on:
postgres:
condition: service_healthy
- weaviate:
- condition: service_healthy
redis:
condition: service_started
vault:
@@ -466,8 +456,6 @@ services:
TERMINAL_IMAGE: ${TERMINAL_IMAGE}
TERMINAL_POD_TTL: ${TERMINAL_POD_TTL}
depends_on:
- weaviate:
- condition: service_healthy
postgres:
condition: service_healthy
vault:
@@ -493,46 +481,6 @@ services:
retries: 5
restart: unless-stopped
- weaviate:
- image: cr.weaviate.io/semitechnologies/weaviate:1.27.6
- container_name: weaviate
- logging: *default-logging
- depends_on:
- postgres:
- condition: service_healthy
- command:
- - --host
- - 0.0.0.0
- - --port
- - "8080"
- - --scheme
- - http
- ports:
- - "8080:8080"
- - "50051:50051"
- volumes:
- - weaviate_data:/var/lib/weaviate
- restart: unless-stopped
- environment:
- AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: "true"
- PERSISTENCE_DATA_PATH: "/var/lib/weaviate"
- ENABLE_API_BASED_MODULES: "true"
- CLUSTER_HOSTNAME: "node1"
- ENABLE_MODULES: "text2vec-transformers"
- TRANSFORMERS_INFERENCE_API: "http://t2v-transformers:8080"
- healthcheck:
- test:
- [
- "CMD",
- "wget",
- "-q",
- "--spider",
- "http://localhost:8080/v1/.well-known/ready",
- ]
- interval: 10s
- timeout: 5s
- retries: 3
-
aurora-mcp:
build:
context: ./server
@@ -561,13 +509,6 @@ services:
retries: 3
start_period: 10s
- t2v-transformers:
- image: cr.weaviate.io/semitechnologies/transformers-inference:sentence-transformers-all-MiniLM-L6-v2
- logging: *default-logging
- environment:
- ENABLE_CUDA: '0'
- restart: unless-stopped
-
frontend:
build:
context: ./client
@@ -764,7 +705,6 @@ services:
volumes:
postgres-data:
- weaviate_data:
terraform-workdir:
seaweedfs_master_data:
seaweedfs_volume_data:
diff --git a/docker-compose.yaml b/docker-compose.yaml
index 770e831b6..5fc6e621c 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -35,17 +35,11 @@ x-common-env: &common-env
# Terraform
TF_CLI_CONFIG_FILE: /etc/terraform.rc
- # Weaviate
- WEAVIATE_HOST: ${WEAVIATE_HOST}
- WEAVIATE_PORT: ${WEAVIATE_PORT}
- WEAVIATE_GRPC_PORT: ${WEAVIATE_GRPC_PORT}
-
# SSL/TLS for external services
POSTGRES_SSLMODE: ${POSTGRES_SSLMODE:-prefer}
POSTGRES_SSLROOTCERT: ${POSTGRES_SSLROOTCERT:-}
REDIS_SSL_CA_CERTS: ${REDIS_SSL_CA_CERTS:-}
REDIS_SSL_CERT_REQS: ${REDIS_SSL_CERT_REQS:-}
- WEAVIATE_SECURE: ${WEAVIATE_SECURE:-false}
SPLUNK_SSL_VERIFY: ${SPLUNK_SSL_VERIFY:-false}
# AWS base credentials for role assumption
@@ -70,6 +64,7 @@ x-common-env: &common-env
SUMMARIZATION_MODEL: ${SUMMARIZATION_MODEL}
AGENT_RECURSION_LIMIT: ${AGENT_RECURSION_LIMIT}
GEMINI_DISABLE_THINKING: ${GEMINI_DISABLE_THINKING}
+ EMBEDDING_MODEL: ${EMBEDDING_MODEL}
# GitHub auth — every Aurora service that talks to GitHub (server,
# celery_worker, chatbot) needs the auth mode + App credentials to mint
@@ -268,7 +263,6 @@ services:
OVH_US_CLIENT_SECRET: ${OVH_US_CLIENT_SECRET}
depends_on:
- postgres
- - weaviate
- redis
- vault
- seaweedfs-filer
@@ -443,8 +437,6 @@ services:
TERMINAL_IMAGE: ${TERMINAL_IMAGE}
TERMINAL_POD_TTL: ${TERMINAL_POD_TTL}
depends_on:
- weaviate:
- condition: service_healthy
postgres:
condition: service_healthy
vault:
@@ -470,46 +462,6 @@ services:
timeout: 5s
retries: 5
- weaviate:
- image: cr.weaviate.io/semitechnologies/weaviate:1.27.6
- container_name: weaviate
- logging: *default-logging
- depends_on:
- postgres:
- condition: service_healthy
- command:
- - --host
- - 0.0.0.0
- - --port
- - "8080"
- - --scheme
- - http
- ports:
- - "8080:8080"
- - "50051:50051"
- volumes:
- - weaviate_data:/var/lib/weaviate
- restart: on-failure:0
- environment:
- AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: "true"
- PERSISTENCE_DATA_PATH: "/var/lib/weaviate"
- ENABLE_API_BASED_MODULES: "true"
- CLUSTER_HOSTNAME: "node1"
- ENABLE_MODULES: "text2vec-transformers"
- TRANSFORMERS_INFERENCE_API: "http://t2v-transformers:8080"
- healthcheck:
- test:
- [
- "CMD",
- "wget",
- "-q",
- "--spider",
- "http://localhost:8080/v1/.well-known/ready",
- ]
- interval: 10s
- timeout: 5s
- retries: 3
-
aurora-mcp:
build:
context: ./server
@@ -537,12 +489,6 @@ services:
retries: 3
start_period: 10s
- t2v-transformers:
- image: cr.weaviate.io/semitechnologies/transformers-inference:sentence-transformers-all-MiniLM-L6-v2
- logging: *default-logging
- environment:
- ENABLE_CUDA: '0'
-
frontend:
build:
context: ./client
@@ -735,7 +681,6 @@ services:
volumes:
postgres-data:
- weaviate_data:
terraform-workdir:
seaweedfs_master_data:
seaweedfs_volume_data:
diff --git a/scripts/package-airtight.sh b/scripts/package-airtight.sh
index fdf1b6113..2a0040098 100755
--- a/scripts/package-airtight.sh
+++ b/scripts/package-airtight.sh
@@ -29,8 +29,6 @@ THIRD_PARTY_IMAGES=(
"amazon/aws-cli:2.34.6"
"memgraph/memgraph-mage:3.8.1"
"memgraph/lab:3.8.0"
- "cr.weaviate.io/semitechnologies/weaviate:1.27.6"
- "cr.weaviate.io/semitechnologies/transformers-inference:sentence-transformers-all-MiniLM-L6-v2"
)
AURORA_IMAGES=(
diff --git a/scripts/validate-env-vars.sh b/scripts/validate-env-vars.sh
index 395557faa..cf6e550eb 100755
--- a/scripts/validate-env-vars.sh
+++ b/scripts/validate-env-vars.sh
@@ -90,7 +90,7 @@ grep -v '^AURORA_SERVICE_ACCOUNT_JSON$' "$USED_VARS_FILE" > "${USED_VARS_FILE}.t
grep -v -E '^(AURORA_AGENT_TOKEN|AGENT_VERSION)$' "$USED_VARS_FILE" > "${USED_VARS_FILE}.tmp" && mv "${USED_VARS_FILE}.tmp" "$USED_VARS_FILE"
# Docker Compose static variables (hardcoded in docker-compose.yml, not user-configurable)
-grep -v -E '^(CHATBOT_HOST|CHATBOT_PORT|WEAVIATE_PORT)$' "$USED_VARS_FILE" > "${USED_VARS_FILE}.tmp" && mv "${USED_VARS_FILE}.tmp" "$USED_VARS_FILE"
+grep -v -E '^(CHATBOT_HOST|CHATBOT_PORT)$' "$USED_VARS_FILE" > "${USED_VARS_FILE}.tmp" && mv "${USED_VARS_FILE}.tmp" "$USED_VARS_FILE"
# Derived variables (set in docker-compose from NEXT_PUBLIC_* counterparts, not user-configurable)
grep -v -E '^(PUBLIC_API_URL|PUBLIC_WS_URL)$' "$USED_VARS_FILE" > "${USED_VARS_FILE}.tmp" && mv "${USED_VARS_FILE}.tmp" "$USED_VARS_FILE"
diff --git a/server/aurora_mcp/registry.py b/server/aurora_mcp/registry.py
index 4a9325e29..33b908eb2 100644
--- a/server/aurora_mcp/registry.py
+++ b/server/aurora_mcp/registry.py
@@ -576,16 +576,6 @@ class DispatchEntry:
path="/api/incidents/recent-unlinked",
enabling_skills=(),
),
- DispatchEntry(
- name="incident_submit_feedback",
- description="Submit feedback on the RCA/postmortem of an incident.",
- category="incidents",
- method="POST",
- path="/api/incidents/{incident_id}/feedback",
- enabling_skills=(),
- path_args=("incident_id",),
- body_keys=("rating", "comment", "category"),
- ),
DispatchEntry(
name="incident_merge_alert",
description="Merge an alert into an existing incident.",
@@ -616,24 +606,6 @@ class DispatchEntry:
enabling_skills=(),
path_args=("suggestion_id",),
),
- # ----- Knowledge base reads -----
- DispatchEntry(
- name="kb_get_memory",
- description="Read the org's persistent memory / context content.",
- category="docs",
- method="GET",
- path="/api/knowledge-base/memory",
- enabling_skills=(),
- ),
- DispatchEntry(
- name="kb_get_document",
- description="Fetch a knowledge-base document's metadata and status.",
- category="docs",
- method="GET",
- path="/api/knowledge-base/documents/{doc_id}",
- enabling_skills=(),
- path_args=("doc_id",),
- ),
# ----- Splunk async search workflow -----
DispatchEntry(
name="splunk_create_search_job",
diff --git a/server/aurora_mcp/resources.py b/server/aurora_mcp/resources.py
index a3b28c962..ca51ae5fb 100644
--- a/server/aurora_mcp/resources.py
+++ b/server/aurora_mcp/resources.py
@@ -122,7 +122,7 @@ async def _do_incidents_recent(api_call: ApiCall) -> Dict[str, Any]:
async def _do_runbooks_index(api_call: ApiCall) -> Dict[str, Any]:
sources = (
- ("knowledge_base", "/api/knowledge-base/documents"),
+ ("memory", "/api/memory/entries?category=runbook"),
("sharepoint", "/sharepoint/sites"),
)
results = await asyncio.gather(
@@ -199,5 +199,5 @@ async def runbooks_index() -> Dict[str, Any]:
@mcp.resource("aurora://health")
async def health() -> Dict[str, Any]:
- """Aurora system health: database, Redis, Weaviate, Celery status."""
+ """Aurora system health: database, Redis, Celery status."""
return await _do_health(api_call)
diff --git a/server/aurora_mcp/tools_always_on.py b/server/aurora_mcp/tools_always_on.py
index 23e1ec5c3..bda4542d3 100644
--- a/server/aurora_mcp/tools_always_on.py
+++ b/server/aurora_mcp/tools_always_on.py
@@ -75,34 +75,45 @@ async def _do_trigger_rca(
return truncate_payload(result, tool_name="trigger_rca")
-async def _do_knowledge_base_search(api_call: ApiCall, query: str, limit: int) -> Dict[str, Any]:
+async def _do_list_memories(api_call: ApiCall, category: str) -> Dict[str, Any]:
+ params = f"?category={category}" if category else ""
return truncate_payload(
- await api_call(
- "POST",
- "/api/knowledge-base/search",
- body={"query": query, "limit": limit},
- ),
- tool_name="knowledge_base_search",
+ await api_call("GET", f"/api/memory/entries{params}"),
+ tool_name="list_memories",
+ )
+
+
+async def _do_read_memory(api_call: ApiCall, entry_id: str) -> Dict[str, Any]:
+ return truncate_payload(
+ await api_call("GET", f"/api/memory/entries/{entry_id}"),
+ tool_name="read_memory",
)
async def _do_search_runbooks(api_call: ApiCall, query: str, limit: int) -> Dict[str, Any]:
- kb_call = api_call("POST", "/api/knowledge-base/search",
- body={"query": query, "limit": limit})
+ mem_call = api_call("GET", "/api/memory/entries?category=runbook")
sp_call = api_call("POST", "/sharepoint/search",
body={"query": query, "maxResults": limit})
- kb_res, sp_res = await asyncio.gather(kb_call, sp_call, return_exceptions=True)
+ mem_res, sp_res = await asyncio.gather(mem_call, sp_call, return_exceptions=True)
sources: List[Dict[str, Any]] = []
- if isinstance(kb_res, Exception):
+ if isinstance(mem_res, Exception):
logger.exception(
- "search_runbooks: knowledge_base call failed", exc_info=kb_res,
+ "search_runbooks: memory call failed", exc_info=mem_res,
)
- sources.append({"source": "knowledge_base", "error": "search_failed"})
+ sources.append({"source": "memory", "error": "search_failed"})
else:
- sources.append({"source": "knowledge_base", "results": kb_res})
- # SharePoint silently skipped on error — likely not connected; callers
- # should hit the explicit `sharepoint_search` dispatch entry for the error.
+ # Filter memory results by query relevance — all query tokens must appear
+ # in the title or description (case-insensitive, any order)
+ entries = mem_res.get("entries", []) if isinstance(mem_res, dict) else []
+ tokens = query.lower().split()
+ filtered = []
+ for e in entries:
+ searchable = f"{e.get('title', '') or ''} {e.get('description', '') or ''}".lower()
+ if all(tok in searchable for tok in tokens):
+ filtered.append(e)
+ mem_res_filtered = {**mem_res, "entries": filtered[:limit]} if isinstance(mem_res, dict) else mem_res
+ sources.append({"source": "memory", "results": mem_res_filtered})
if not isinstance(sp_res, Exception):
sources.append({"source": "sharepoint", "results": sp_res})
@@ -296,13 +307,18 @@ async def trigger_rca(
)
@mcp.tool()
- async def knowledge_base_search(query: str, limit: int = 5) -> Dict[str, Any]:
- """Semantic search across Aurora's knowledge base (uploaded docs, indexed runbooks)."""
- return await _do_knowledge_base_search(api_call, query, limit)
+ async def list_memories(category: str = "") -> Dict[str, Any]:
+ """List memory entries. Optionally filter by category: context, runbook, infrastructure, learned, postmortem."""
+ return await _do_list_memories(api_call, category)
+
+ @mcp.tool()
+ async def read_memory(entry_id: str) -> Dict[str, Any]:
+ """Read a single memory entry by ID. Use list_memories first to find the ID."""
+ return await _do_read_memory(api_call, entry_id)
@mcp.tool()
async def search_runbooks(query: str, limit: int = 5) -> Dict[str, Any]:
- """Search runbooks/docs across the Aurora knowledge base and SharePoint
+ """Search runbooks/docs across org memory and SharePoint
(when connected). Confluence has no search endpoint — fetch specific
Confluence pages via call_tool('confluence_fetch_page', { url })."""
return await _do_search_runbooks(api_call, query, limit)
diff --git a/server/celery_config.py b/server/celery_config.py
index 30480674a..3cedaac04 100644
--- a/server/celery_config.py
+++ b/server/celery_config.py
@@ -97,7 +97,8 @@
'chat.background.summarization',
'chat.background.visualization_generator',
'chat.background.prediscovery_task',
- 'routes.knowledge_base.tasks',
+ 'services.memory.migration_task',
+ 'services.memory.collector',
'services.discovery.tasks',
'utils.aws.credential_refresh',
'routes.aws.cloudwatch_tasks',
@@ -117,10 +118,6 @@
'task': 'chat.background.cleanup_stale_sessions',
'schedule': 300.0, # Every 5 minutes
},
- 'cleanup-stale-kb-documents': {
- 'task': 'knowledge_base.cleanup_stale_documents',
- 'schedule': 180.0, # Every 3 minutes
- },
'run-full-discovery': {
'task': 'services.discovery.tasks.run_full_discovery',
'schedule': float(os.getenv('DISCOVERY_INTERVAL_HOURS', '1')) * 3600, # Default: every hour
diff --git a/server/chat/backend/agent/access/mode_access_controller.py b/server/chat/backend/agent/access/mode_access_controller.py
index 27bd0a8ae..5a364cf21 100644
--- a/server/chat/backend/agent/access/mode_access_controller.py
+++ b/server/chat/backend/agent/access/mode_access_controller.py
@@ -46,7 +46,6 @@ class ModeAccessController:
safe_tool_names=(
"web_search",
"analyze_zip_file",
- "rag_index_zip",
),
blocked_tool_prefixes=("mcp_",), # Block MCP tools by default
iac_safe_actions=(
diff --git a/server/chat/backend/agent/agent.py b/server/chat/backend/agent/agent.py
index 9fffd5789..988562f83 100644
--- a/server/chat/backend/agent/agent.py
+++ b/server/chat/backend/agent/agent.py
@@ -5,7 +5,6 @@
from chat.backend.agent.llm import LLMManager, ModelConfig
from chat.backend.agent.model_mapper import ModelMapper
from chat.backend.agent.providers import create_chat_model, get_registry
-from chat.backend.agent.weaviate_client import WeaviateClient
from chat.backend.agent.utils.state import State
from chat.backend.agent.utils.tool_context_capture import ToolContextCapture
from langchain_core.tools import StructuredTool
@@ -14,6 +13,7 @@
from chat.backend.agent.utils.prefix_cache import PrefixCacheManager
from chat.backend.agent.prompt.prompt_builder import build_prompt_segments, assemble_system_prompt, register_prompt_cache_breakpoints
from chat.backend.agent.utils.llm_usage_tracker import LLMUsageTracker, LLMUsage
+from services.memory.injector import MemoryPrefetch
import time
import asyncio
from datetime import datetime, timezone
@@ -82,13 +82,12 @@ def _convert_chunk_to_generation_chunk(self, chunk, default_chunk_class, base_ge
return result
class Agent:
- def __init__(self, weaviate_client: WeaviateClient, postgres_client: PostgreSQLClient, websocket_sender=None, event_loop=None, ctx_len=10):
+ def __init__(self, postgres_client: PostgreSQLClient, websocket_sender=None, event_loop=None, ctx_len=10) -> None:
self.llm_manager = LLMManager()
self.postgres_client = postgres_client
- self.weaviate_client = weaviate_client
self.ctx_len = ctx_len
- self.websocket_sender = websocket_sender # Store websocket_sender directly
- self.event_loop = event_loop # Store event loop for thread-safe async calls
+ self.websocket_sender = websocket_sender
+ self.event_loop = event_loop
def set_tool_capture(self, tool_capture):
"""Set the tool capture instance to be used by this agent."""
@@ -346,6 +345,24 @@ async def agentic_tool_flow(
getattr(state, 'attachments', []),
)
+ # Fire async memory prefetch — runs in parallel with prompt building and tool setup.
+ _memory_prefetch = None
+ if state.user_id and state.session_id:
+ try:
+ _last_msg_content = ""
+ if state.messages:
+ _lm = state.messages[-1]
+ _last_msg_content = _lm.content if isinstance(getattr(_lm, 'content', ''), str) else str(getattr(_lm, 'content', ''))
+ if _last_msg_content:
+ _memory_prefetch = MemoryPrefetch(
+ user_id=state.user_id,
+ session_id=state.session_id,
+ user_message=_last_msg_content,
+ )
+ _memory_prefetch.start()
+ except Exception as _mpe:
+ logging.debug("Failed to start memory prefetch: %s", _mpe)
+
# Build prompt segments in background while getting tools on main thread
# (get_cloud_tools needs thread-local context for tool_capture/user resolution)
from concurrent.futures import ThreadPoolExecutor as _TPE
@@ -360,6 +377,16 @@ async def agentic_tool_flow(
tools = get_cloud_tools()
segments = _prompt_future.result()
+ # Collect prefetch result and append injected memories to the prompt
+ if _memory_prefetch:
+ try:
+ injected = await _memory_prefetch.get_result_async(timeout=6.0)
+ if injected:
+ memory_index = segments.knowledge_base_memory or ""
+ segments.knowledge_base_memory = (memory_index + "\n\n" + injected) if memory_index else injected
+ except Exception:
+ logging.debug("Memory prefetch result unavailable")
+
system_prompt_text = assemble_system_prompt(segments)
if system_prompt_override is not None:
system_prompt_text = system_prompt_override
@@ -378,7 +405,7 @@ async def agentic_tool_flow(
prompt_text = ' '.join([p['text'] if isinstance(p, dict) and p.get('type') == 'text' else str(p) for p in last_content])
# Only include zip-related tools if referenced
if not self._prompt_references_zip(prompt_text, getattr(state, 'attachments', [])):
- tools = [t for t in tools if getattr(t, 'name', None) not in ('analyze_zip_file', 'rag_index_zip')]
+ tools = [t for t in tools if getattr(t, 'name', None) not in ('analyze_zip_file',)]
# Register canonicalized prefix + tools with cache middleware.
# Skip for sub-agents: their `system_prompt_override` (the role brief)
diff --git a/server/chat/backend/agent/orchestrator/roles/general_investigator.md b/server/chat/backend/agent/orchestrator/roles/general_investigator.md
index 89577b974..5f71f6f0a 100644
--- a/server/chat/backend/agent/orchestrator/roles/general_investigator.md
+++ b/server/chat/backend/agent/orchestrator/roles/general_investigator.md
@@ -1,7 +1,7 @@
---
name: general_investigator
description: Universal escape hatch — use for ANY investigation that doesn't cleanly fit a specialist (DNS, TLS/cert validity, third-party API health, queue depth, data correctness, config drift, IAM/permission audits, build provenance, dependency-version mismatches, anything novel). Prefer a specialist when one clearly fits; otherwise spawn one or more of these with a tightly-bounded purpose per spawn.
-tools: [logs, error_tracking, observability, runbooks, knowledge_base, source_control_read, ci_cd, metrics, runtime_state, ticket_history, on_call]
+tools: [logs, error_tracking, observability, runbooks, memory, source_control_read, ci_cd, metrics, runtime_state, ticket_history, on_call]
model:
max_turns: 26
max_seconds: 600
diff --git a/server/chat/backend/agent/orchestrator/roles/runbook_lookup.md b/server/chat/backend/agent/orchestrator/roles/runbook_lookup.md
index 22e51a1c6..2f0a8fd0b 100644
--- a/server/chat/backend/agent/orchestrator/roles/runbook_lookup.md
+++ b/server/chat/backend/agent/orchestrator/roles/runbook_lookup.md
@@ -1,20 +1,20 @@
---
name: runbook_lookup
description: Use when incident type matches a known failure pattern that may have an existing runbook or SOP
-tools: [runbooks, knowledge_base]
+tools: [runbooks, memory]
model:
max_turns: 26
max_seconds: 600
rca_priority: 30
---
-You are a runbook and knowledge-base specialist. Your scope is locating existing runbooks, post-mortems, or standard operating procedures that match this incident's failure pattern.
+You are a runbook and memory specialist. Your scope is locating existing runbooks, post-mortems, or standard operating procedures that match this incident's failure pattern.
-Search the knowledge base and runbook systems for documents matching the affected service, error type, and symptoms. Rank matches by relevance. Extract the diagnosis criteria and recommended response steps from the top match.
+Search org memory (runbook/postmortem categories) and connected runbook systems for documents matching the affected service, error type, and symptoms. Rank matches by relevance. Extract the diagnosis criteria and recommended response steps from the top match.
**You must NOT:**
- Execute any runbook steps — your role is retrieval only.
-- Modify any knowledge-base documents.
+- Modify any memory entries.
- Suggest new runbook content in your findings (that belongs in a post-mortem, not here).
**Findings structure:** Cite the runbook title, document ID, and the specific section that matches this incident in `citations`. If no matching runbook exists, state that clearly and note this as a gap.
diff --git a/server/chat/backend/agent/orchestrator/select_skills.py b/server/chat/backend/agent/orchestrator/select_skills.py
index 680cbad68..23fbe3399 100644
--- a/server/chat/backend/agent/orchestrator/select_skills.py
+++ b/server/chat/backend/agent/orchestrator/select_skills.py
@@ -49,15 +49,20 @@
"github_fix": {"capability_tags": ["source_control_write"], "mutates": True, "cacheable": False},
# github_apply_fix is NOT exposed to agents — see note in cloud_tools.py.
"iac_tool": {"capability_tags": ["iac"], "mutates": True, "cacheable": False},
- # Runbooks + knowledge base
- "confluence_runbook_parse": {"capability_tags": ["runbooks", "knowledge_base"], "mutates": False, "cacheable": True},
- "knowledge_base_search": {"capability_tags": ["knowledge_base", "runbooks"], "mutates": False, "cacheable": True},
+ # Runbooks + memory
+ "confluence_runbook_parse": {"capability_tags": ["runbooks", "memory"], "mutates": False, "cacheable": True},
+ "list_memories": {"capability_tags": ["memory", "runbooks"], "mutates": False, "cacheable": True},
+ "read_memory": {"capability_tags": ["memory", "runbooks"], "mutates": False, "cacheable": True},
+ "write_memory": {"capability_tags": ["memory"], "mutates": True, "cacheable": False},
+ "append_to_memory": {"capability_tags": ["memory"], "mutates": True, "cacheable": False},
+ "edit_memory": {"capability_tags": ["memory"], "mutates": True, "cacheable": False},
+ "grep_memories": {"capability_tags": ["memory", "runbooks"], "mutates": False, "cacheable": True},
# Ticket / incident history
"list_incidentio_incidents": {"capability_tags": ["ticket_history", "on_call"], "mutates": False, "cacheable": True},
"get_incidentio_incident": {"capability_tags": ["ticket_history", "on_call"], "mutates": False, "cacheable": True},
"get_incidentio_timeline": {"capability_tags": ["ticket_history", "on_call"], "mutates": False, "cacheable": True},
# General research
- "web_search": {"capability_tags": ["knowledge_base"], "mutates": False, "cacheable": True},
+ "web_search": {"capability_tags": ["memory"], "mutates": False, "cacheable": True},
# Bitbucket — source control + CI (mirror of github tagging)
"bitbucket_repos": {"capability_tags": ["source_control_read"], "mutates": False, "cacheable": True},
"bitbucket_branches": {"capability_tags": ["source_control_read"], "mutates": False, "cacheable": True},
@@ -72,9 +77,9 @@
"cloudflare_list_zones": {"capability_tags": ["observability"], "mutates": False, "cacheable": True},
"cloudflare_action": {"capability_tags": [], "mutates": True, "cacheable": False},
# Confluence runbook-search (confluence_runbook_parse already above)
- "confluence_search_similar": {"capability_tags": ["runbooks", "knowledge_base"], "mutates": False, "cacheable": True},
- "confluence_search_runbooks": {"capability_tags": ["runbooks", "knowledge_base"], "mutates": False, "cacheable": True},
- "confluence_fetch_page": {"capability_tags": ["runbooks", "knowledge_base"], "mutates": False, "cacheable": True},
+ "confluence_search_similar": {"capability_tags": ["runbooks", "memory"], "mutates": False, "cacheable": True},
+ "confluence_search_runbooks": {"capability_tags": ["runbooks", "memory"], "mutates": False, "cacheable": True},
+ "confluence_fetch_page": {"capability_tags": ["runbooks", "memory"], "mutates": False, "cacheable": True},
# Coroot — observability platform
"coroot_get_incidents": {"capability_tags": ["ticket_history", "observability"], "mutates": False, "cacheable": True},
"coroot_get_incident_detail": {"capability_tags": ["ticket_history", "observability"], "mutates": False, "cacheable": True},
@@ -97,15 +102,15 @@
"jira_update_issue": {"capability_tags": [], "mutates": True, "cacheable": False},
"jira_link_issues": {"capability_tags": [], "mutates": True, "cacheable": False},
# Notion — read-only investigation tools (write tools default to mutates=True via the catch-all below)
- "notion_search": {"capability_tags": ["runbooks", "knowledge_base"], "mutates": False, "cacheable": True},
- "notion_fetch": {"capability_tags": ["runbooks", "knowledge_base"], "mutates": False, "cacheable": True},
- "notion_query_database": {"capability_tags": ["knowledge_base"], "mutates": False, "cacheable": True},
- "notion_query_data_source": {"capability_tags": ["knowledge_base"], "mutates": False, "cacheable": True},
- "notion_get_block_children": {"capability_tags": ["knowledge_base"], "mutates": False, "cacheable": True},
+ "notion_search": {"capability_tags": ["runbooks", "memory"], "mutates": False, "cacheable": True},
+ "notion_fetch": {"capability_tags": ["runbooks", "memory"], "mutates": False, "cacheable": True},
+ "notion_query_database": {"capability_tags": ["memory"], "mutates": False, "cacheable": True},
+ "notion_query_data_source": {"capability_tags": ["memory"], "mutates": False, "cacheable": True},
+ "notion_get_block_children": {"capability_tags": ["memory"], "mutates": False, "cacheable": True},
# SharePoint
- "sharepoint_search": {"capability_tags": ["runbooks", "knowledge_base"], "mutates": False, "cacheable": True},
- "sharepoint_fetch_page": {"capability_tags": ["runbooks", "knowledge_base"], "mutates": False, "cacheable": True},
- "sharepoint_fetch_document": {"capability_tags": ["runbooks", "knowledge_base"], "mutates": False, "cacheable": True},
+ "sharepoint_search": {"capability_tags": ["runbooks", "memory"], "mutates": False, "cacheable": True},
+ "sharepoint_fetch_page": {"capability_tags": ["runbooks", "memory"], "mutates": False, "cacheable": True},
+ "sharepoint_fetch_document": {"capability_tags": ["runbooks", "memory"], "mutates": False, "cacheable": True},
"sharepoint_create_page": {"capability_tags": [], "mutates": True, "cacheable": False},
# ThousandEyes — network observability
"thousandeyes_list_tests": {"capability_tags": ["metrics", "observability"], "mutates": False, "cacheable": True},
@@ -175,7 +180,7 @@ def _resolve_connected_tool_filter(user_id: str) -> tuple[set, set]:
- ``skill_owned_tools``: every tool name listed by ANY registered skill.
Tools NOT in this set are considered always-available built-ins
- (e.g. ``cloud_exec``, ``knowledge_base_search``, ``web_search``).
+ (e.g. ``cloud_exec``, ``list_memories``, ``web_search``).
- ``connected_tools``: tools owned by at least one skill the user has
currently connected. A skill-owned tool only passes the filter if it
appears here.
diff --git a/server/chat/backend/agent/orchestrator/sub_agent.py b/server/chat/backend/agent/orchestrator/sub_agent.py
index 2cdca9458..0773d8cec 100644
--- a/server/chat/backend/agent/orchestrator/sub_agent.py
+++ b/server/chat/backend/agent/orchestrator/sub_agent.py
@@ -482,7 +482,6 @@ async def _run(input_dict: dict) -> FindingRef:
try:
from chat.backend.agent.agent import Agent
from chat.backend.agent.db import PostgreSQLClient
- from chat.backend.agent.weaviate_client import WeaviateClient
from chat.backend.agent.utils.state import State
from langchain_core.messages import HumanMessage
@@ -506,10 +505,8 @@ async def _run(input_dict: dict) -> FindingRef:
)
postgres_client = PostgreSQLClient()
- weaviate_client = WeaviateClient(postgres_client)
try:
agent = Agent(
- weaviate_client=weaviate_client,
postgres_client=postgres_client,
)
if tool_capture is not None:
@@ -524,10 +521,6 @@ async def _run(input_dict: dict) -> FindingRef:
logger.info("sub_agent: agent completed for agent=%s incident=%s", inp.agent_id, inc_hash)
finally:
- try:
- weaviate_client.close()
- except Exception:
- logger.exception("sub_agent: failed to close weaviate_client for agent=%s", inp.agent_id)
try:
postgres_client.close()
except Exception:
diff --git a/server/chat/backend/agent/prompt/__init__.py b/server/chat/backend/agent/prompt/__init__.py
index ee9045bc2..f40533aa6 100644
--- a/server/chat/backend/agent/prompt/__init__.py
+++ b/server/chat/backend/agent/prompt/__init__.py
@@ -19,7 +19,6 @@
build_background_mode_segment,
build_ephemeral_rules,
build_failure_recovery_segment,
- build_knowledge_base_memory_segment,
build_long_documents_note,
build_manual_vm_access_segment,
build_model_overlay_segment,
@@ -41,7 +40,6 @@
"build_background_mode_segment",
"build_ephemeral_rules",
"build_failure_recovery_segment",
- "build_knowledge_base_memory_segment",
"build_long_documents_note",
"build_manual_vm_access_segment",
"build_model_overlay_segment",
diff --git a/server/chat/backend/agent/prompt/background.py b/server/chat/backend/agent/prompt/background.py
index a542b43c2..ce5cad061 100644
--- a/server/chat/backend/agent/prompt/background.py
+++ b/server/chat/backend/agent/prompt/background.py
@@ -1,71 +1,46 @@
from __future__ import annotations
-from functools import lru_cache
import logging
-import os
from typing import Any, Dict, List, Optional
-logger = logging.getLogger(__name__)
-
-BACKGROUND_RCA_SEGMENTS_DIR = os.path.normpath(
- os.path.join(
- os.path.dirname(__file__),
- os.pardir,
- "skills",
- "rca",
- "background",
- )
+from chat.backend.agent.skills.loader import (
+ load_core_prompt,
+ resolve_template,
+ CORE_SKILLS_DIR,
+ BACKGROUND_RCA_DIR,
)
-
-@lru_cache(maxsize=32)
-def _load_background_segment_template(segment_name: str) -> str:
- """Load a background RCA markdown segment by name (filename without .md)."""
- try:
- from chat.backend.agent.skills.loader import load_core_prompt
-
- return load_core_prompt(BACKGROUND_RCA_SEGMENTS_DIR, segments=[segment_name]).strip()
- except Exception as e:
- logger.warning(f"Failed to load background RCA segment '{segment_name}': {e}")
- return ""
-
-
-def _render_background_segment(
- segment_name: str,
- context: Optional[Dict[str, Any]] = None,
-) -> str:
- """Render a background segment with optional {variable} replacements."""
- template = _load_background_segment_template(segment_name)
- if not template:
- return ""
-
- if not context:
- return template
-
- try:
- from chat.backend.agent.skills.loader import resolve_template
-
- return resolve_template(template, context)
- except Exception as e:
- logger.warning(f"Failed to render background RCA segment '{segment_name}': {e}")
- return template
+logger = logging.getLogger(__name__)
-def _append_background_segment(
+def _append_segment(
parts: List[str],
segment_name: str,
+ base_dir: str = BACKGROUND_RCA_DIR,
context: Optional[Dict[str, Any]] = None,
leading_blank: bool = False,
trailing_blank: bool = False,
) -> None:
- """Append rendered background segment text with optional surrounding blanks."""
- rendered = _render_background_segment(segment_name, context=context)
- if not rendered:
+ """Load a segment .md file and append it to the prompt parts list."""
+ try:
+ content = load_core_prompt(base_dir, segments=[segment_name]).strip()
+ except Exception as e:
+ logger.warning(f"Failed to load segment '{segment_name}': {e}")
+ return
+
+ if not content:
return
+ if context:
+ try:
+ content = resolve_template(content, context)
+ except Exception as e:
+ logger.warning("Failed to render segment '%s': %s", segment_name, e)
+ return
+
if leading_blank:
parts.append("")
- parts.append(rendered)
+ parts.append(content)
if trailing_blank:
parts.append("")
@@ -91,7 +66,7 @@ def build_background_mode_segment(state: Optional[Any]) -> str:
providers_tools_display = ", ".join(providers) if providers else "none"
parts: List[str] = []
- _append_background_segment(
+ _append_segment(
parts,
"background_header",
context={
@@ -100,7 +75,7 @@ def build_background_mode_segment(state: Optional[Any]) -> str:
},
trailing_blank=True,
)
- _append_background_segment(
+ _append_segment(
parts,
"background_provider_tools",
context={"providers_tools_display": providers_tools_display},
@@ -130,9 +105,10 @@ def build_background_mode_segment(state: Optional[Any]) -> str:
# Integration-specific guidance (Splunk, Datadog, GitHub, Jira, etc.)
# now loaded from skill files above via SkillRegistry.load_skills_for_rca().
- _append_background_segment(parts, "background_knowledge_base", leading_blank=True)
- _append_background_segment(parts, "background_vm_access", leading_blank=True)
- _append_background_segment(
+ # Memory behavioral contract — shared with foreground chat (single source of truth)
+ _append_segment(parts, "memory", leading_blank=True, base_dir=CORE_SKILLS_DIR)
+ _append_segment(parts, "background_vm_access", leading_blank=True)
+ _append_segment(
parts,
"background_context_update",
leading_blank=True,
@@ -141,11 +117,11 @@ def build_background_mode_segment(state: Optional[Any]) -> str:
# Critical requirements - MUST complete all before stopping
if source == 'slack':
- _append_background_segment(parts, "background_source_slack", leading_blank=True)
+ _append_segment(parts, "background_source_slack", leading_blank=True)
elif source == 'google_chat':
- _append_background_segment(parts, "background_source_google_chat", leading_blank=True)
+ _append_segment(parts, "background_source_google_chat", leading_blank=True)
else:
- _append_background_segment(
+ _append_segment(
parts,
"background_source_general",
context={"providers_display": providers_display},
@@ -155,9 +131,9 @@ def build_background_mode_segment(state: Optional[Any]) -> str:
# Non-Anthropic models often don't produce text between tool calls unless instructed to
model_name = (getattr(state, 'model', '') or '').lower()
if model_name and not model_name.startswith("anthropic/"):
- _append_background_segment(parts, "background_source_general_non_anthropic")
+ _append_segment(parts, "background_source_general_non_anthropic")
- _append_background_segment(parts, "background_source_general_footer")
+ _append_segment(parts, "background_source_general_footer")
return "\n".join(parts)
diff --git a/server/chat/backend/agent/prompt/composer.py b/server/chat/backend/agent/prompt/composer.py
index ffcf7da1f..a21dc77ff 100644
--- a/server/chat/backend/agent/prompt/composer.py
+++ b/server/chat/backend/agent/prompt/composer.py
@@ -5,10 +5,8 @@
from typing import Any, List, Optional
from .background import build_background_mode_segment
-from .context_fetchers import (
- build_knowledge_base_memory_segment,
- build_manual_vm_access_segment,
-)
+from .context_fetchers import build_manual_vm_access_segment
+from services.memory.index_builder import build_memory_index
from .provider_rules import (
build_ephemeral_rules,
build_failure_recovery_segment,
@@ -62,7 +60,7 @@ def build_system_invariant(is_background: bool = False) -> str:
return load_core_prompt(core_dir, segments=[
"identity",
"security",
- "knowledge_base",
+ "memory",
"tool_selection",
"ssh_access",
"cloud_access",
@@ -126,10 +124,16 @@ def build_prompt_segments(
except Exception as e:
logging.warning(f"Failed to build skills index: {e}")
- # Build knowledge base memory context for authenticated users
- knowledge_base_memory = ""
- if state and hasattr(state, 'user_id'):
- knowledge_base_memory = build_knowledge_base_memory_segment(state.user_id)
+ # Build memory index for authenticated users (static TOC of title + description).
+ # The injector's full-content memories are appended in agent.py after this returns.
+ memory_context = ""
+ user_id = getattr(state, "user_id", None) if state else None
+ if user_id:
+ try:
+ memory_context = build_memory_index(user_id) or ""
+ except Exception:
+ logging.exception("Failed to build memory index for prompt")
+ memory_context = ""
# Build org-level command policy segment
security_policy = ""
@@ -160,7 +164,7 @@ def build_prompt_segments(
failure_recovery=failure_recovery,
background_mode=background_mode,
manual_vm_access=manual_vm_access,
- knowledge_base_memory=knowledge_base_memory,
+ knowledge_base_memory=memory_context,
integration_index=integration_index,
security_policy=security_policy,
is_rca_background=is_background and not is_action,
diff --git a/server/chat/backend/agent/prompt/context_fetchers.py b/server/chat/backend/agent/prompt/context_fetchers.py
index 44f622945..d02886bdf 100644
--- a/server/chat/backend/agent/prompt/context_fetchers.py
+++ b/server/chat/backend/agent/prompt/context_fetchers.py
@@ -6,6 +6,8 @@
from typing import Optional
from utils.db.connection_pool import db_pool
+from utils.auth.stateless_auth import set_rls_context
+from utils.db.org_scope import resolve_org, org_read_predicate
def build_manual_vm_access_segment(user_id: Optional[str]) -> str:
@@ -14,8 +16,6 @@ def build_manual_vm_access_segment(user_id: Optional[str]) -> str:
return ""
try:
- from utils.auth.stateless_auth import set_rls_context
- from utils.db.org_scope import resolve_org, org_read_predicate
org_id = resolve_org(user_id)
_, pred_params = org_read_predicate(user_id, org_id)
vm_predicate = "(mv.user_id = %s OR mv.org_id = %s)" if org_id else "mv.user_id = %s"
@@ -72,56 +72,3 @@ def build_manual_vm_access_segment(user_id: Optional[str]) -> str:
lines.append(f"- {name}{label_str}: {base_cmd} {user_display}@{ip} -p {port} \"\"")
return "\n".join(lines) + "\n"
-
-
-def build_knowledge_base_memory_segment(user_id: Optional[str]) -> str:
- """Build knowledge base memory segment for system prompt.
-
- Fetches the org's knowledge base memory content and formats it for injection
- into the system prompt. This content is always included for authenticated users.
- """
- if not user_id:
- return ""
-
- kb_logger = logging.getLogger(__name__)
-
- try:
- with db_pool.get_admin_connection() as conn:
- cursor = conn.cursor()
- # No RLS needed — users, knowledge_base_memory not RLS-protected
- cursor.execute(
- "SELECT org_id FROM users WHERE id = %s", (user_id,)
- )
- user_row = cursor.fetchone()
- org_id = user_row[0] if user_row else None
-
- if org_id:
- cursor.execute(
- "SELECT content FROM knowledge_base_memory WHERE org_id = %s ORDER BY updated_at DESC LIMIT 1",
- (org_id,)
- )
- else:
- cursor.execute(
- "SELECT content FROM knowledge_base_memory WHERE user_id = %s ORDER BY updated_at DESC LIMIT 1",
- (user_id,)
- )
- row = cursor.fetchone()
-
- if row and row[0] and row[0].strip():
- content = row[0].strip()
- # Escape curly braces for LangChain template compatibility
- content = content.replace("{", "{{").replace("}", "}}")
-
- return (
- "=" * 40 + "\n"
- "USER-PROVIDED CONTEXT (Knowledge Base Memory)\n"
- "=" * 40 + "\n"
- "The user has provided the following context that should inform your analysis:\n\n"
- f"{content}\n\n"
- "Consider this context when investigating issues and making recommendations.\n"
- "=" * 40 + "\n"
- )
- except Exception as e:
- kb_logger.warning(f"[KB] Error fetching knowledge base memory for user {user_id}: {e}")
-
- return ""
diff --git a/server/chat/backend/agent/prompt/prompt_builder.py b/server/chat/backend/agent/prompt/prompt_builder.py
index 44c6df24c..da7d1e2d3 100644
--- a/server/chat/backend/agent/prompt/prompt_builder.py
+++ b/server/chat/backend/agent/prompt/prompt_builder.py
@@ -20,7 +20,6 @@
build_system_invariant,
)
from .context_fetchers import (
- build_knowledge_base_memory_segment,
build_manual_vm_access_segment,
)
from .provider_rules import (
@@ -46,7 +45,6 @@
"build_background_mode_segment",
"build_ephemeral_rules",
"build_failure_recovery_segment",
- "build_knowledge_base_memory_segment",
"build_long_documents_note",
"build_manual_vm_access_segment",
"build_model_overlay_segment",
diff --git a/server/chat/backend/agent/prompt/schema.py b/server/chat/backend/agent/prompt/schema.py
index 4e24d8916..1a29470ac 100644
--- a/server/chat/backend/agent/prompt/schema.py
+++ b/server/chat/backend/agent/prompt/schema.py
@@ -15,7 +15,7 @@ class PromptSegments:
failure_recovery: str
manual_vm_access: str = "" # Manual VM access hints with managed keys
background_mode: str = "" # Background chat autonomous operation instructions
- knowledge_base_memory: str = "" # User's knowledge base memory context
+ knowledge_base_memory: str = "" # Org memory index (context, runbooks, infra, learnings)
integration_index: str = "" # Skills-based: compact index of connected integrations
security_policy: str = "" # Org-level command allow/deny policy
is_rca_background: bool = False # True when prompt is for a background RCA (not action)
diff --git a/server/chat/backend/agent/skills/__init__.py b/server/chat/backend/agent/skills/__init__.py
index 9b1bfa07d..61fab4435 100644
--- a/server/chat/backend/agent/skills/__init__.py
+++ b/server/chat/backend/agent/skills/__init__.py
@@ -8,6 +8,13 @@
from .registry import SkillRegistry
from .load_skill_tool import load_skill, LoadSkillArgs
-from .loader import load_core_prompt
+from .loader import (
+ load_core_prompt,
+ CORE_SKILLS_DIR,
+ BACKGROUND_RCA_DIR,
+)
-__all__ = ["SkillRegistry", "load_skill", "LoadSkillArgs", "load_core_prompt"]
+__all__ = [
+ "SkillRegistry", "load_skill", "LoadSkillArgs", "load_core_prompt",
+ "CORE_SKILLS_DIR", "BACKGROUND_RCA_DIR",
+]
diff --git a/server/chat/backend/agent/skills/core/knowledge_base.md b/server/chat/backend/agent/skills/core/knowledge_base.md
deleted file mode 100644
index b20fc6513..000000000
--- a/server/chat/backend/agent/skills/core/knowledge_base.md
+++ /dev/null
@@ -1,24 +0,0 @@
-KNOWLEDGE BASE (CRITICAL - CHECK FIRST FOR RUNBOOKS AND CONTEXT):
-knowledge_base_search(query, limit) - Search user's uploaded documentation:
-- ALWAYS search the knowledge base at the START of any investigation
-- ALSO call get_infrastructure_context() at the start if the issue involves services, deployments, or topology
-- Contains runbooks, architecture docs, postmortems, and team-specific procedures
-- Contains auto-discovered infrastructure topology (deployment chains, dependencies, monitoring mappings)
-- Returns relevant excerpts with source file attribution
-- WHEN TO SEARCH:
- 1. At the START of every investigation - check for existing runbooks AND infrastructure topology
- 2. When encountering unfamiliar services or systems
- 3. When seeing error patterns that might match past incidents
- 4. Before providing recommendations - check for documented procedures
-- QUERY EXAMPLES:
- - 'payment-service deployment chain dependencies'
- - 'redis connection timeout'
- - 'what connects to database X'
- - 'escalation process database'
-- IMPORTANT: Reference knowledge base findings with source citations in your analysis
-- If a runbook exists for the issue, FOLLOW the documented steps
-
-INFRASTRUCTURE CONTEXT:
-get_infrastructure_context() - Retrieve the full infrastructure context document:
-- Returns a comprehensive document covering environments, services, dependencies, CI/CD, and monitoring. Gives a big picture of overall infrastructure context that can be helpful to understand an org's setup.
-- Complements knowledge_base_search: KB has runbooks/procedures, this has full system topology
diff --git a/server/chat/backend/agent/skills/core/memory.md b/server/chat/backend/agent/skills/core/memory.md
new file mode 100644
index 000000000..78eb6f993
--- /dev/null
+++ b/server/chat/backend/agent/skills/core/memory.md
@@ -0,0 +1,95 @@
+MEMORY SYSTEM — BEHAVIORAL CONTRACT:
+You have access to a persistent memory system shared across all conversations for this organization.
+Build up this memory over time so that future conversations have complete context about the org's infrastructure, procedures, patterns, and preferences.
+
+WHEN TO WRITE MEMORY:
+- infrastructure: When you discover service topology, deployment chains, monitoring configs, or dependencies during investigation.
+- runbook: When the user shares or you identify step-by-step procedures for known issues.
+- context: When you learn team preferences, escalation paths, on-call structures, org policies, or behavioral instructions (how to respond, what to avoid, communication style, workflows the user wants you to follow, conventions, or any other org/user-specific guidance).
+- learned: After resolving an incident where the root cause or resolution was non-obvious. Save the pattern so future investigations can match against it.
+- postmortem: When a postmortem is generated or the user shares one — save it to memory so future investigations can reference it.
+
+
+WHEN TO ACCESS MEMORY:
+- When memories seem relevant to the current conversation or investigation.
+- You MUST access memory when the user explicitly asks you to check, recall, or remember.
+- If the user says to ignore or not use memory: proceed as if no memories exist.
+ Do not apply remembered facts, cite, compare against, or mention memory content.
+
+HOW TO VERIFY BEFORE ACTING ON MEMORY:
+- If memory names a service or resource: verify it still exists via your tools
+- If memory names a procedure: confirm the steps are still valid (commands, endpoints)
+- If memory is old: treat as possibly stale and verify key facts
+- NEVER blindly trust "learned" memories as root causes for current incidents.
+ Learned patterns are hypotheses from past investigations — they can mislead if the
+ current issue has different underlying causes. Always verify with fresh evidence.
+
+WHEN TO SAVE IMMEDIATELY:
+- The user explicitly asks you to remember something
+- The user corrects your approach or confirms a non-obvious approach worked
+
+WHAT NOT TO SAVE:
+- Ephemeral investigation data (logs, metrics snapshots, one-off commands)
+- Information already in the memory index (don't duplicate)
+- Sensitive credentials or tokens (these belong in Vault, not memory)
+- Incident-specific details that won't generalize (use artifacts for those)
+
+IF THE USER ASKS TO FORGET:
+- Find and delete the relevant memory entry immediately
+
+MAINTENANCE:
+- Use append_to_memory() to add findings to an existing entry (preferred over full rewrites)
+- Use edit_memory() for surgical corrections (fix a typo, update a value, remove outdated info)
+- Use write_memory() only for entirely new entries — it will reject if the title already exists
+- Use write_memory(overwrite=true) only when you intentionally want to replace an existing entry
+- Use grep_memories() to search across content when you're not sure which entry has what you need
+- Keep entries focused — one topic per entry, not mega-documents
+- Use clear titles that scan well in the index
+
+MEMORY FORMAT:
+When writing or updating memory, follow this structure:
+
+ write_memory(
+ category='',
+ title='',
+ description='',
+ content=''
+ )
+
+The description field is critical — it's what appears in the memory index and determines whether
+this entry gets found in future conversations. Make it specific and searchable.
+
+Content structure by category:
+
+ learned / context:
+
+ **Why:**
+ **How to apply:**
+
+ infrastructure:
+
+
+ runbook:
+ ## Symptoms
+
+ ## Steps
+ 1.
+ 2.
+ ## Escalation
+
+
+Examples:
+
+ write_memory(
+ category='learned',
+ title='Redis cluster-1 Sunday flapping',
+ description='Redis cluster-1 flaps every Sunday 2-3am UTC during backup cron — not a real incident',
+ content='Redis cluster-1 reports connection drops every Sunday between 02:00-03:00 UTC.\n**Why:** The weekly RDB backup (cron on redis-backup-prod) causes brief master failovers.\n**How to apply:** If alerts fire in this window, wait 10min before investigating. Suppress payment-api-latency alerts during this period.'
+ )
+
+ write_memory(
+ category='context',
+ title='Response style preferences',
+ description='Team prefers concise bullet responses, no verbose explanations unless asked',
+ content='Keep responses concise — bullet points preferred over paragraphs.\n**Why:** Team uses Aurora during active incidents where time matters.\n**How to apply:** Lead with the answer/action, add explanation only if asked or non-obvious.'
+ )
diff --git a/server/chat/backend/agent/skills/loader.py b/server/chat/backend/agent/skills/loader.py
index f0fb26b5d..7e7834baa 100644
--- a/server/chat/backend/agent/skills/loader.py
+++ b/server/chat/backend/agent/skills/loader.py
@@ -17,6 +17,12 @@
# Regex to split YAML frontmatter (between --- delimiters) from body
_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)", re.DOTALL)
+# Standard skill directories (relative to this file)
+_SKILLS_ROOT = os.path.normpath(os.path.join(os.path.dirname(__file__)))
+
+CORE_SKILLS_DIR = os.path.join(_SKILLS_ROOT, "core")
+BACKGROUND_RCA_DIR = os.path.join(_SKILLS_ROOT, "rca", "background")
+
@dataclass
class SkillMetadata:
diff --git a/server/chat/backend/agent/skills/rca/background/background_knowledge_base.md b/server/chat/backend/agent/skills/rca/background/background_knowledge_base.md
deleted file mode 100644
index 5f7560060..000000000
--- a/server/chat/backend/agent/skills/rca/background/background_knowledge_base.md
+++ /dev/null
@@ -1,4 +0,0 @@
-KNOWLEDGE BASE:
-Use knowledge_base_search tool to find runbooks and documentation:
-- knowledge_base_search(query='service name error') - Search for relevant docs
-- Check KB FIRST for troubleshooting procedures before cloud investigation
diff --git a/server/chat/backend/agent/tools/cloud_tools.py b/server/chat/backend/agent/tools/cloud_tools.py
index e9308a0e8..80372f504 100644
--- a/server/chat/backend/agent/tools/cloud_tools.py
+++ b/server/chat/backend/agent/tools/cloud_tools.py
@@ -48,7 +48,6 @@
from .zip_file_tool import analyze_zip_file
from .cloud_provider_utils import determine_target_provider_from_context
-from .rag_indexer_tool import rag_index_zip, RAGIndexZipArgs
from .web_search_tool import web_search, WebSearchArgs
from .terminal_exec_tool import terminal_exec
from .tailscale_ssh_tool import tailscale_ssh, is_tailscale_connected
@@ -1737,18 +1736,6 @@ def _pinned_trigger(action_id: str = "", _pid=pinned_id, _fn=final_func, **kw):
description="Analyze a ZIP attachment: list, extract a file, or detect project structure",
))
- # Add RAG indexer for ZIPs
- tools.append(StructuredTool.from_function(
- func=rag_index_zip,
- name="rag_index_zip",
- description=(
- "Index code/text files from an uploaded ZIP into the RAG store (Weaviate). "
- "Arguments: attachment_index (int)=0, max_files (int)=200, max_file_bytes (int)=750000, "
- "include_patterns (list[str]) and exclude_dirs (list[str])."
- ),
- args_schema=RAGIndexZipArgs,
- ))
-
# Add load_skill tool for on-demand integration guidance
try:
from chat.backend.agent.skills.load_skill_tool import load_skill as _load_skill, LoadSkillArgs
@@ -1776,56 +1763,140 @@ def _pinned_trigger(action_id: str = "", _pid=pinned_id, _fn=final_func, **kw):
except Exception as e:
logging.warning(f"Failed to register load_skill tool: {e}")
- # Add Knowledge Base search tool for authenticated users
+ # Add Memory tools for authenticated users
try:
- from chat.backend.agent.tools.knowledge_base_search_tool import (
- knowledge_base_search,
- KnowledgeBaseSearchArgs,
- KNOWLEDGE_BASE_SEARCH_DESCRIPTION,
+ from chat.backend.agent.tools.memory_tool import (
+ list_memories,
+ read_memory,
+ write_memory,
+ ListMemoriesArgs,
+ ReadMemoryArgs,
+ WriteMemoryArgs,
)
- context_wrapped_kb = with_user_context(knowledge_base_search)
- notification_wrapped_kb = with_completion_notification(context_wrapped_kb)
+ context_wrapped_lm = with_user_context(list_memories)
+ notification_wrapped_lm = with_completion_notification(context_wrapped_lm)
if tool_capture:
- final_kb_func = wrap_func_with_capture(notification_wrapped_kb, "knowledge_base_search")
+ final_lm_func = wrap_func_with_capture(notification_wrapped_lm, "list_memories")
else:
- final_kb_func = notification_wrapped_kb
+ final_lm_func = notification_wrapped_lm
tools.append(StructuredTool.from_function(
- func=final_kb_func,
- name="knowledge_base_search",
- description=KNOWLEDGE_BASE_SEARCH_DESCRIPTION,
- args_schema=KnowledgeBaseSearchArgs,
+ func=final_lm_func,
+ name="list_memories",
+ description=(
+ "List org memory entries, optionally filtered by category "
+ "Use to discover available knowledge before reading specific entries."
+ ),
+ args_schema=ListMemoriesArgs,
))
- logging.info(f"Added knowledge_base_search tool for user {user_id}")
- except Exception as e:
- logging.warning(f"Failed to add knowledge_base_search tool: {e}")
- # Add get_infrastructure_context for all authenticated users
- if user_id:
- try:
- from chat.backend.agent.tools.infra_context_tool import (
- get_infrastructure_context,
- GetInfraContextArgs,
- GET_INFRA_CONTEXT_DESCRIPTION,
- )
+ context_wrapped_rm = with_user_context(read_memory)
+ notification_wrapped_rm = with_completion_notification(context_wrapped_rm)
+ if tool_capture:
+ final_rm_func = wrap_func_with_capture(notification_wrapped_rm, "read_memory")
+ else:
+ final_rm_func = notification_wrapped_rm
- context_wrapped_ic = with_user_context(get_infrastructure_context)
- notification_wrapped_ic = with_completion_notification(context_wrapped_ic)
- if tool_capture:
- final_ic_func = wrap_func_with_capture(notification_wrapped_ic, "get_infrastructure_context")
- else:
- final_ic_func = notification_wrapped_ic
+ tools.append(StructuredTool.from_function(
+ func=final_rm_func,
+ name="read_memory",
+ description=(
+ "Read the full markdown content of a specific memory entry by category and title. "
+ "Use the ORG MEMORY index in your system prompt to find available entries, "
+ "then read the ones relevant to your investigation."
+ ),
+ args_schema=ReadMemoryArgs,
+ ))
+
+ context_wrapped_wm = with_user_context(write_memory)
+ notification_wrapped_wm = with_completion_notification(context_wrapped_wm)
+ if tool_capture:
+ final_wm_func = wrap_func_with_capture(notification_wrapped_wm, "write_memory")
+ else:
+ final_wm_func = notification_wrapped_wm
+ if not is_pr_review:
tools.append(StructuredTool.from_function(
- func=final_ic_func,
- name="get_infrastructure_context",
- description=GET_INFRA_CONTEXT_DESCRIPTION,
- args_schema=GetInfraContextArgs,
+ func=final_wm_func,
+ name="write_memory",
+ description=(
+ "Create or overwrite an org memory entry (full content replacement). "
+ "Prefer append_to_memory for adding new info to existing entries, or "
+ "edit_memory for surgical changes. Use write_memory only for new entries "
+ "or when the entire content needs replacing."
+ ),
+ args_schema=WriteMemoryArgs,
))
- logging.info(f"Added get_infrastructure_context tool for user {user_id}")
- except Exception as e:
- logging.warning(f"Failed to add get_infrastructure_context tool: {e}")
+
+ from chat.backend.agent.tools.memory_tool import (
+ append_to_memory,
+ edit_memory,
+ grep_memories,
+ AppendToMemoryArgs,
+ EditMemoryArgs,
+ GrepMemoriesArgs,
+ )
+
+ context_wrapped_am = with_user_context(append_to_memory)
+ notification_wrapped_am = with_completion_notification(context_wrapped_am)
+ if tool_capture:
+ final_am_func = wrap_func_with_capture(notification_wrapped_am, "append_to_memory")
+ else:
+ final_am_func = notification_wrapped_am
+
+ if not is_pr_review:
+ tools.append(StructuredTool.from_function(
+ func=final_am_func,
+ name="append_to_memory",
+ description=(
+ "Append content to the end of an existing memory entry without rewriting it. "
+ "Creates the entry if it doesn't exist. Use for incrementally adding findings, "
+ "new sections, or updates to an existing document."
+ ),
+ args_schema=AppendToMemoryArgs,
+ ))
+
+ context_wrapped_em = with_user_context(edit_memory)
+ notification_wrapped_em = with_completion_notification(context_wrapped_em)
+ if tool_capture:
+ final_em_func = wrap_func_with_capture(notification_wrapped_em, "edit_memory")
+ else:
+ final_em_func = notification_wrapped_em
+
+ if not is_pr_review:
+ tools.append(StructuredTool.from_function(
+ func=final_em_func,
+ name="edit_memory",
+ description=(
+ "Find and replace specific text within a memory entry. Like sed — make surgical "
+ "edits without rewriting the whole entry. old_text must match exactly. "
+ "Read the entry first to see current content before editing."
+ ),
+ args_schema=EditMemoryArgs,
+ ))
+
+ context_wrapped_gm = with_user_context(grep_memories)
+ notification_wrapped_gm = with_completion_notification(context_wrapped_gm)
+ if tool_capture:
+ final_gm_func = wrap_func_with_capture(notification_wrapped_gm, "grep_memories")
+ else:
+ final_gm_func = notification_wrapped_gm
+
+ tools.append(StructuredTool.from_function(
+ func=final_gm_func,
+ name="grep_memories",
+ description=(
+ "Search across all memory content for a keyword or phrase. Returns matching "
+ "snippets with context. Use to find information across entries without reading "
+ "each one individually."
+ ),
+ args_schema=GrepMemoriesArgs,
+ ))
+
+ logging.info(f"Added memory tools (list, read, write, append, edit, grep) for user {user_id}")
+ except Exception as e:
+ logging.warning(f"Failed to add memory tools: {e}")
# Introspection tools — incident/infra/actions/metrics self-audit (always available)
if user_id:
@@ -1848,7 +1919,6 @@ def _pinned_trigger(action_id: str = "", _pid=pinned_id, _fn=final_func, **kw):
get_action, GetActionArgs,
graph_get_service, GraphGetServiceArgs,
postmortem_list, PostmortemListArgs,
- kb_get_memory, KbGetMemoryArgs,
grafana_list_alerts, GrafanaListAlertsArgs,
)
@@ -1982,14 +2052,6 @@ def _pinned_trigger(action_id: str = "", _pid=pinned_id, _fn=final_func, **kw):
"(Confluence/Jira/Notion). Use to discover existing postmortems.",
PostmortemListArgs,
),
- (
- kb_get_memory,
- "kb_get_memory",
- "Read the org's persistent knowledge base memory — a shared context "
- "document that teams maintain with org-specific conventions, runbook "
- "references, and operational notes.",
- KbGetMemoryArgs,
- ),
(
grafana_list_alerts,
"grafana_list_alerts",
@@ -2018,56 +2080,6 @@ def _pinned_trigger(action_id: str = "", _pid=pinned_id, _fn=final_func, **kw):
except Exception as e:
logging.warning(f"Failed to add introspection tools: {e}")
- # Add discovery finding tool for prediscovery mode
- if mode_suffix == "prediscovery":
- try:
- from chat.backend.agent.tools.discovery_finding_tool import (
- save_discovery_finding,
- DiscoveryFindingArgs,
- DISCOVERY_FINDING_DESCRIPTION,
- )
-
- context_wrapped_df = with_user_context(save_discovery_finding)
- notification_wrapped_df = with_completion_notification(context_wrapped_df)
- if tool_capture:
- final_df_func = wrap_func_with_capture(notification_wrapped_df, "save_discovery_finding")
- else:
- final_df_func = notification_wrapped_df
-
- tools.append(StructuredTool.from_function(
- func=final_df_func,
- name="save_discovery_finding",
- description=DISCOVERY_FINDING_DESCRIPTION,
- args_schema=DiscoveryFindingArgs,
- ))
- logging.info(f"Added save_discovery_finding tool for prediscovery mode")
- except Exception as e:
- logging.warning(f"Failed to add save_discovery_finding tool: {e}")
-
- try:
- from chat.backend.agent.tools.infra_context_tool import (
- save_infrastructure_context,
- SaveInfraContextArgs,
- SAVE_INFRA_CONTEXT_DESCRIPTION,
- )
-
- context_wrapped_sic = with_user_context(save_infrastructure_context)
- notification_wrapped_sic = with_completion_notification(context_wrapped_sic)
- if tool_capture:
- final_sic_func = wrap_func_with_capture(notification_wrapped_sic, "save_infrastructure_context")
- else:
- final_sic_func = notification_wrapped_sic
-
- tools.append(StructuredTool.from_function(
- func=final_sic_func,
- name="save_infrastructure_context",
- description=SAVE_INFRA_CONTEXT_DESCRIPTION,
- args_schema=SaveInfraContextArgs,
- ))
- logging.info("Added save_infrastructure_context tool for prediscovery mode")
- except Exception as e:
- logging.warning(f"Failed to add save_infrastructure_context tool: {e}")
-
# Add Splunk tools if connected
if is_splunk_connected(user_id):
# search_splunk tool
diff --git a/server/chat/backend/agent/tools/discovery_finding_tool.py b/server/chat/backend/agent/tools/discovery_finding_tool.py
deleted file mode 100644
index ccff60ac3..000000000
--- a/server/chat/backend/agent/tools/discovery_finding_tool.py
+++ /dev/null
@@ -1,117 +0,0 @@
-"""
-Discovery Finding Tool
-
-Agent tool for saving infrastructure discovery findings to the knowledge base.
-Used by the prediscovery agent to persist interconnection mappings.
-"""
-
-import logging
-import uuid
-from datetime import datetime, timezone
-
-from pydantic import BaseModel, Field
-
-logger = logging.getLogger(__name__)
-
-DISCOVERY_DOC_PREFIX = "discovery:"
-
-
-class DiscoveryFindingArgs(BaseModel):
- """Arguments for saving a discovery finding."""
-
- title: str = Field(
- description="Short title for this finding, e.g. 'payment-service deployment chain'"
- )
- content: str = Field(
- description=(
- "Structured description of the interconnected services. Include service names, "
- "providers, connection types, repos, pipelines, monitoring, and how they relate."
- )
- )
- tags: str = Field(
- default="",
- description="Comma-separated tags for categorization, e.g. 'github,jenkins,k8s,prod'",
- )
-
-
-def save_discovery_finding(
- title: str,
- content: str,
- tags: str = "",
- user_id: str | None = None,
- session_id: str | None = None,
- **kwargs,
-) -> str:
- """
- Save an infrastructure discovery finding to the knowledge base.
-
- Call this every time you discover how services are interconnected.
- Each finding should describe one logical chain or cluster of related services.
-
- Args:
- title: Short descriptive title for the finding
- content: Structured description of interconnected services
- tags: Comma-separated tags for categorization
- user_id: User identifier (injected by context wrapper)
- session_id: Session identifier (injected by context wrapper)
-
- Returns:
- Confirmation message or error
- """
- if not user_id:
- return "Error: User authentication required."
-
- if not title or not content:
- return "Error: Both title and content are required."
-
- try:
- from routes.knowledge_base.weaviate_client import insert_chunks
- from utils.auth.stateless_auth import get_org_id_for_user
-
- org_id = get_org_id_for_user(user_id)
- if not org_id:
- logger.warning(f"[Discovery] No org_id for user {user_id}, skipping (findings would be unsearchable)")
- return "Error: No organization context. Discovery findings require org scope."
- document_id = f"{DISCOVERY_DOC_PREFIX}{datetime.now(timezone.utc).strftime('%Y%m%d')}:{uuid.uuid4().hex[:8]}"
-
- chunks = [{
- "content": content,
- "heading_context": tags.strip() if tags else "infrastructure-discovery",
- "chunk_index": 0,
- }]
-
- inserted = insert_chunks(
- user_id=user_id,
- document_id=document_id,
- source_filename=f"[Auto-Discovery] {title}",
- chunks=chunks,
- org_id=org_id,
- )
-
- if inserted > 0:
- logger.info(f"[Discovery] Saved finding '{title}' (doc={document_id}) for org {org_id}")
- return f"Finding saved: '{title}' ({inserted} chunk(s) stored in knowledge base)"
- else:
- return "Warning: Finding was not stored. Weaviate may be unavailable."
-
- except Exception as e:
- logger.exception(f"[Discovery] Error saving finding: {e}")
- return f"Error saving finding: {str(e)}"
-
-
-DISCOVERY_FINDING_DESCRIPTION = """Save an infrastructure discovery finding to the knowledge base.
-
-Call this EVERY TIME you discover how services are interconnected. Do not accumulate findings - save each one immediately.
-
-Each finding should describe one logical chain or cluster:
-- Deployment chains: repo -> CI/CD pipeline -> deployment target
-- Service dependencies: service A -> database B, cache C
-- Monitoring mappings: which monitors/dashboards watch which services
-- Network topology: load balancer -> backends, VPC groupings
-
-Example:
- save_discovery_finding(
- title='payment-api deployment chain',
- content='GitHub repo org/payment-api deploys via Jenkins job payment-deploy to K8s cluster prod-east namespace payments. Image: ecr/payment-api. Depends on: RDS db-payments, ElastiCache redis-sessions. Monitored by Datadog monitors payment-api-latency and payment-api-errors.',
- tags='github,jenkins,k8s,aws,datadog'
- )"""
diff --git a/server/chat/backend/agent/tools/infra_context_tool.py b/server/chat/backend/agent/tools/infra_context_tool.py
deleted file mode 100644
index 67c4dfbbb..000000000
--- a/server/chat/backend/agent/tools/infra_context_tool.py
+++ /dev/null
@@ -1,116 +0,0 @@
-"""
-Infrastructure Context Tool
-
-Save and retrieve the consolidated infrastructure context document.
-The prediscovery agent writes this; internal and MCP agents read it.
-"""
-
-import logging
-
-from pydantic import BaseModel, Field
-
-logger = logging.getLogger(__name__)
-
-
-class SaveInfraContextArgs(BaseModel):
- content: str = Field(
- description=(
- "The full consolidated infrastructure context document. "
- "Covers environments, services, dependencies, CI/CD, monitoring, "
- "and how they interconnect."
- )
- )
-
-
-class GetInfraContextArgs(BaseModel):
- pass
-
-
-SAVE_INFRA_CONTEXT_DESCRIPTION = (
- "Save the consolidated infrastructure context document. Call this ONCE at the "
- "end of your investigation with the complete synthesized document covering all "
- "environments, services, dependencies, CI/CD pipelines, and monitoring."
-)
-
-GET_INFRA_CONTEXT_DESCRIPTION = (
- "Retrieve the infrastructure context document for this organization. Contains "
- "environments, services, dependencies, CI/CD pipelines, and monitoring topology. "
- "Call when you need to understand system architecture or service relationships."
-)
-
-
-def save_infrastructure_context(
- content: str,
- user_id: str | None = None,
- **kwargs,
-) -> str:
- """Upsert the infrastructure context document for the user's org."""
- if not user_id:
- return "Error: User authentication required."
- if not content or len(content.strip()) < 100:
- return "Error: Content too short. Provide a comprehensive infrastructure document."
- if len(content.strip()) > 100_000:
- return "Error: Content too large (max 100k characters)."
-
- try:
- from utils.auth.stateless_auth import get_org_id_for_user
- from utils.db.connection_pool import db_pool
-
- org_id = get_org_id_for_user(user_id)
- if not org_id:
- return "Error: No organization context."
-
- with db_pool.get_admin_connection() as conn:
- with conn.cursor() as cur:
- cur.execute(
- """
- INSERT INTO infrastructure_context (org_id, content, updated_at)
- VALUES (%s, %s, NOW())
- ON CONFLICT (org_id) DO UPDATE
- SET content = EXCLUDED.content, updated_at = NOW()
- """,
- (org_id, content.strip()),
- )
- conn.commit()
-
- logger.info(f"[InfraContext] Saved infrastructure context for org {org_id} ({len(content)} chars)")
- return "Infrastructure context saved successfully."
-
- except Exception as e:
- logger.exception(f"[InfraContext] Error saving context: {e}")
- return f"Error saving infrastructure context: {str(e)}"
-
-
-def get_infrastructure_context(
- user_id: str | None = None,
- **kwargs,
-) -> str:
- """Retrieve the infrastructure context document for the user's org."""
- if not user_id:
- return "Error: User authentication required."
-
- try:
- from utils.auth.stateless_auth import get_org_id_for_user
- from utils.db.connection_pool import db_pool
-
- org_id = get_org_id_for_user(user_id)
- if not org_id:
- return "Error: No organization context."
-
- with db_pool.get_admin_connection() as conn:
- with conn.cursor() as cur:
- cur.execute(
- "SELECT content, updated_at FROM infrastructure_context WHERE org_id = %s",
- (org_id,),
- )
- row = cur.fetchone()
-
- if not row:
- return "No infrastructure context available yet. Run prediscovery to generate it."
-
- content, updated_at = row
- return f"[Last updated: {updated_at.isoformat()}]\n\n{content}"
-
- except Exception as e:
- logger.exception(f"[InfraContext] Error retrieving context: {e}")
- return f"Error retrieving infrastructure context: {str(e)}"
diff --git a/server/chat/backend/agent/tools/introspection_tools.py b/server/chat/backend/agent/tools/introspection_tools.py
index d0c8a4adf..c130592f7 100644
--- a/server/chat/backend/agent/tools/introspection_tools.py
+++ b/server/chat/backend/agent/tools/introspection_tools.py
@@ -778,12 +778,20 @@ def postmortem_list(limit=50, offset=0, *, user_id, **_) -> dict:
with _cursor(user_id) as (cur, org_id):
cur.execute(
- """SELECT p.id, p.incident_id, p.generated_at, p.updated_at, i.alert_title,
- p.confluence_page_url, p.jira_issue_url, p.notion_page_url
- FROM postmortems p
- LEFT JOIN incidents i ON p.incident_id = i.id
- WHERE p.org_id = %s
- ORDER BY p.generated_at DESC LIMIT %s OFFSET %s""",
+ """SELECT a.id, a.incident_id, a.created_at, a.updated_at, i.alert_title,
+ pe_confluence.external_url AS confluence_url,
+ pe_jira.external_url AS jira_url,
+ pe_notion.external_url AS notion_url
+ FROM artifacts a
+ LEFT JOIN incidents i ON a.incident_id = i.id
+ LEFT JOIN postmortem_exports pe_confluence
+ ON pe_confluence.postmortem_id = a.id AND pe_confluence.destination = 'confluence'
+ LEFT JOIN postmortem_exports pe_jira
+ ON pe_jira.postmortem_id = a.id AND pe_jira.destination = 'jira'
+ LEFT JOIN postmortem_exports pe_notion
+ ON pe_notion.postmortem_id = a.id AND pe_notion.destination = 'notion'
+ WHERE a.org_id = %s AND a.category = 'postmortem'
+ ORDER BY a.created_at DESC LIMIT %s OFFSET %s""",
(org_id, limit, offset),
)
rows = cur.fetchall()
@@ -799,30 +807,6 @@ def postmortem_list(limit=50, offset=0, *, user_id, **_) -> dict:
return {"postmortems": postmortems, "count": len(postmortems)}
-# ---------------------------------------------------------------------------
-# Knowledge base memory
-# ---------------------------------------------------------------------------
-
-class KbGetMemoryArgs(BaseModel):
- pass
-
-
-@introspection_tool
-def kb_get_memory(*, user_id, **_) -> dict:
- """Read the org's persistent knowledge base memory."""
- with _cursor(user_id) as (cur, org_id):
- cur.execute(
- """SELECT content, updated_at FROM knowledge_base_memory
- WHERE org_id = %s ORDER BY updated_at DESC LIMIT 1""",
- (org_id,),
- )
- row = cur.fetchone()
-
- if row and row[0]:
- return {"content": row[0], "updated_at": iso_utc(row[1])}
- return {"content": "", "updated_at": None}
-
-
# ---------------------------------------------------------------------------
# Grafana alerts (webhook-ingested)
# ---------------------------------------------------------------------------
diff --git a/server/chat/backend/agent/tools/knowledge_base_search_tool.py b/server/chat/backend/agent/tools/knowledge_base_search_tool.py
deleted file mode 100644
index 098e092bb..000000000
--- a/server/chat/backend/agent/tools/knowledge_base_search_tool.py
+++ /dev/null
@@ -1,130 +0,0 @@
-"""
-Knowledge Base Search Tool
-
-Agent tool for searching the user's knowledge base during RCA investigations.
-Uses hybrid search (semantic + keyword) with source attribution.
-"""
-
-import logging
-
-from pydantic import BaseModel, Field
-
-logger = logging.getLogger(__name__)
-
-
-class KnowledgeBaseSearchArgs(BaseModel):
- """Arguments for knowledge base search."""
-
- query: str = Field(
- description="Search query for the knowledge base. Be specific - include service names, error types, or topics."
- )
- limit: int = Field(
- default=5,
- description="Maximum number of results to return (1-10).",
- ge=1,
- le=10,
- )
-
-
-def knowledge_base_search(
- query: str,
- limit: int = 5,
- user_id: str | None = None,
- session_id: str | None = None,
- **kwargs,
-) -> str:
- """
- Search the user's knowledge base for relevant documentation.
-
- Use this tool to find runbooks, architecture docs, and past incident reports.
- Always search at the START of any investigation to check for existing documentation.
-
- Args:
- query: Search query - be specific about the service, error, or topic
- limit: Maximum number of results (default 5)
- user_id: User identifier (injected by context wrapper)
- session_id: Session identifier (injected by context wrapper)
-
- Returns:
- Formatted search results with source citations, or message if no results
- """
- if not user_id:
- return "Error: User authentication required to search knowledge base."
-
- if not query or not query.strip():
- return "Error: Search query is required."
-
- # Clamp limit
- limit = max(1, min(10, limit))
-
- try:
- from routes.knowledge_base.weaviate_client import search_knowledge_base as search_kb
-
- results = search_kb(
- user_id=user_id,
- query=query.strip(),
- limit=limit,
- alpha=0.5,
- min_score=0.0,
- org_id=kwargs.get("org_id"),
- )
-
- if not results:
- return f"No relevant documents found in knowledge base for: '{query}'\n\nConsider:\n- The knowledge base may not have documentation for this topic\n- Try a different search query with alternative terms\n- Proceed with standard investigation approach"
-
- # Format results with source attribution
- output_parts = [f"Found {len(results)} relevant result(s) for: '{query}'\n"]
-
- for i, result in enumerate(results, 1):
- source = result.get("source_filename", "Unknown source")
- heading = result.get("heading_context", "")
- content = result.get("content", "")
- score = result.get("score", 0)
-
- # Build result block
- output_parts.append(f"--- Result {i} ---")
- output_parts.append(f"Source: {source}")
- if heading:
- output_parts.append(f"Section: {heading}")
- output_parts.append(f"Relevance: {score:.2f}")
- output_parts.append("")
- output_parts.append(content.strip())
- output_parts.append("")
-
- output_parts.append("---")
- output_parts.append(
- "Use this context to inform your investigation. "
- "Reference specific documents when providing recommendations."
- )
-
- logger.info(
- f"[KB Tool] Search '{query[:50]}...' returned {len(results)} results for user {user_id}"
- )
-
- return "\n".join(output_parts)
-
- except Exception as e:
- logger.exception(f"[KB Tool] Error searching knowledge base: {e}")
- return f"Error searching knowledge base: {str(e)}\n\nProceeding without knowledge base context."
-
-
-# Tool description for the agent
-KNOWLEDGE_BASE_SEARCH_DESCRIPTION = """Search your knowledge base for relevant documentation, runbooks, or infrastructure topology.
-
-IMPORTANT: Use this tool at the START of any investigation to check for existing documentation.
-
-When to use:
-- Before investigating any service or system
-- When you encounter an error or issue
-- To find runbooks with troubleshooting steps
-- To understand service dependencies and deployment chains (auto-discovery findings)
-- To check for past incident reports or postmortems
-
-Search tips:
-- Include the service name: "payment-service deployment chain"
-- Include the error type: "connection timeout redis"
-- Search for topology: "what connects to database X"
-- Search for procedures: "escalation process database"
-
-Returns relevant excerpts with source file attribution. Results prefixed with [Auto-Discovery] contain
-infrastructure topology mapped automatically (deployment chains, dependencies, monitoring)."""
diff --git a/server/chat/backend/agent/tools/memory_tool.py b/server/chat/backend/agent/tools/memory_tool.py
new file mode 100644
index 000000000..bf5caa1c6
--- /dev/null
+++ b/server/chat/backend/agent/tools/memory_tool.py
@@ -0,0 +1,848 @@
+"""
+Memory Tools
+
+Agent-callable tools for listing, reading, and writing org memory.
+Memory lives in the same artifacts table but is scoped to memory categories
+defined in services.memory.MEMORY_CATEGORIES.
+
+Reuses services/artifacts/store.py for persistence and versioning.
+"""
+
+import json
+import logging
+import re
+from contextlib import contextmanager
+
+from pydantic import BaseModel, Field
+
+from services.memory import MEMORY_CATEGORIES, ALL_CATEGORIES
+from services.artifacts.store import create_version
+from utils.validation import strip_nul
+from utils.db.connection_pool import db_pool
+from utils.auth.stateless_auth import set_rls_context
+
+logger = logging.getLogger(__name__)
+
+_AGENT_WRITE_LIMIT = 500_000 # Max chars the agent can write in a single tool call
+_CATEGORY_HELP = ", ".join(sorted(MEMORY_CATEGORIES))
+_MAX_TITLE = 500
+_GREP_MAX_RESULTS = 10
+_GREP_SNIPPET_CHARS = 200
+
+_NO_USER_CTX = json.dumps({"error": "No user context available."})
+_NO_ORG_CTX = json.dumps({"error": "No organization context available."})
+
+
+# ---------------------------------------------------------------------------
+# Shared infrastructure
+# ---------------------------------------------------------------------------
+
+
+@contextmanager
+def _memory_connection(user_id: str, operation: str):
+ """Context manager that handles DB connection + RLS setup for memory ops.
+
+ Yields (cursor, conn, org_id). Raises ValueError if user/org is missing.
+ """
+ if not user_id:
+ raise ValueError("no_user")
+
+ with db_pool.get_admin_connection() as conn:
+ with conn.cursor() as cursor:
+ org_id = set_rls_context(cursor, conn, user_id, log_prefix=f"[MemoryTool:{operation}]")
+ if not org_id:
+ raise ValueError("no_org")
+ yield cursor, conn, org_id
+
+
+def _validate_category(category: str, allow_empty: bool = False) -> str | None:
+ """Validate category, return error JSON string if invalid, None if OK."""
+ if allow_empty and not category:
+ return None
+ if not category or category not in ALL_CATEGORIES:
+ return json.dumps({
+ "error": f"Invalid category. Must be one of: {', '.join(ALL_CATEGORIES)}"
+ })
+ return None
+
+
+def _validate_title(title: str) -> str | None:
+ """Validate title, return error JSON string if invalid, None if OK."""
+ if not title or not title.strip():
+ return json.dumps({"error": "title is required."})
+ if len(title.strip()) > _MAX_TITLE:
+ return json.dumps({"error": "Title exceeds maximum length (500 chars)."})
+ return None
+
+
+def _error_response(e: ValueError) -> str:
+ """Convert a ValueError from _memory_connection into a JSON response."""
+ if str(e) == "no_user":
+ return _NO_USER_CTX
+ return _NO_ORG_CTX
+
+
+# ---------------------------------------------------------------------------
+# list_memories
+# ---------------------------------------------------------------------------
+
+
+class ListMemoriesArgs(BaseModel):
+ category: str = Field(
+ default="",
+ description=(
+ f"Filter by category: {_CATEGORY_HELP}. "
+ "Leave empty to list all memory entries."
+ ),
+ )
+
+
+def list_memories(category: str = "", user_id: str | None = None, **kwargs) -> str:
+ """List memory entries for the org, optionally filtered by category."""
+ if err := _validate_category(category, allow_empty=True):
+ return err
+
+ try:
+ with _memory_connection(user_id, "list") as (cursor, conn, org_id):
+ # Filter to specific category or all memory categories
+ if category:
+ cat_filter = "category = %s"
+ params = (org_id, category)
+ else:
+ cat_filter = "category = ANY(%s)"
+ params = (org_id, list(MEMORY_CATEGORIES))
+
+ cursor.execute(
+ f"""SELECT title, category, description, last_edited_by, updated_at, LENGTH(content) as content_length
+ FROM artifacts
+ WHERE org_id = %s AND {cat_filter}
+ ORDER BY category, updated_at DESC""",
+ params,
+ )
+ rows = cursor.fetchall()
+
+ entries = [
+ {
+ "title": row[0],
+ "category": row[1],
+ "description": row[2] or "",
+ "last_edited_by": row[3],
+ "updated_at": row[4].isoformat() if row[4] else None,
+ "content_length": row[5] or 0,
+ }
+ for row in rows
+ ]
+ return json.dumps({"status": "ok", "count": len(entries), "memories": entries})
+
+ except ValueError as e:
+ return _error_response(e)
+ except Exception:
+ logger.exception("[MemoryTool] Failed to list memories")
+ return json.dumps({"error": "Failed to list memories."})
+
+
+# ---------------------------------------------------------------------------
+# read_memory
+# ---------------------------------------------------------------------------
+
+
+class ReadMemoryArgs(BaseModel):
+ category: str = Field(description=f"The memory category ({_CATEGORY_HELP})")
+ title: str = Field(description="The exact title of the memory entry to read")
+ offset: int = Field(default=0, description="Character offset to start reading from (for paginating large entries)")
+ limit: int = Field(default=50000, description="Maximum number of characters to return (default 50000). Use with offset to paginate.")
+
+
+def read_memory(category: str, title: str, offset: int = 0, limit: int = 50000, user_id: str | None = None, **kwargs) -> str:
+ """Read one memory entry by category and title. Supports pagination via offset/limit for large documents."""
+ if err := _validate_category(category):
+ return err
+ if err := _validate_title(title):
+ return err
+
+ try:
+ with _memory_connection(user_id, "read") as (cursor, conn, org_id):
+ cursor.execute(
+ """SELECT content, description, last_edited_by, updated_at
+ FROM artifacts
+ WHERE org_id = %s AND category = %s AND title = %s""",
+ (org_id, category, title.strip()),
+ )
+ row = cursor.fetchone()
+
+ if not row:
+ return json.dumps({
+ "status": "not_found",
+ "message": f"No memory entry '{title}' in category '{category}'.",
+ })
+
+ full_content = row[0] or ""
+ total_length = len(full_content)
+ chunk = full_content[offset:offset + limit]
+
+ result = {
+ "status": "ok",
+ "category": category,
+ "title": title.strip(),
+ "content": chunk,
+ "description": row[1] or "",
+ "last_edited_by": row[2],
+ "updated_at": row[3].isoformat() if row[3] else None,
+ "total_length": total_length,
+ "offset": offset,
+ "returned_length": len(chunk),
+ "has_more": offset + limit < total_length,
+ }
+ if offset + limit < total_length:
+ result["next_offset"] = offset + limit
+
+ return json.dumps(result)
+
+ except ValueError as e:
+ return _error_response(e)
+ except Exception:
+ logger.exception("[MemoryTool] Failed to read memory")
+ return json.dumps({"error": "Failed to read memory."})
+
+
+# ---------------------------------------------------------------------------
+# write_memory
+# ---------------------------------------------------------------------------
+
+
+class WriteMemoryArgs(BaseModel):
+ category: str = Field(description=f"The memory category ({_CATEGORY_HELP})")
+ title: str = Field(description="Short descriptive title for this memory entry")
+ content: str = Field(description="The full markdown content of the memory entry")
+ description: str = Field(
+ default="",
+ description="One-line summary for the memory index (helps with future retrieval)",
+ )
+ overwrite: bool = Field(
+ default=False,
+ description="Set to true to replace an existing entry with the same title. If false and the title exists, the write is rejected.",
+ )
+
+
+def write_memory(
+ category: str,
+ title: str,
+ content: str,
+ description: str = "",
+ overwrite: bool = False,
+ user_id: str | None = None,
+ session_id: str | None = None,
+ **kwargs,
+) -> str:
+ """Create a new memory entry, or overwrite an existing one (requires overwrite=true)."""
+ if err := _validate_category(category):
+ return err
+ if err := _validate_title(title):
+ return err
+ if not content or not content.strip():
+ return json.dumps({"error": "content cannot be empty."})
+ if len(content) > _AGENT_WRITE_LIMIT:
+ return json.dumps({"error": "Content exceeds maximum length (500000 chars)."})
+
+ content = strip_nul(content)
+ try:
+ with _memory_connection(user_id, "write") as (cursor, conn, org_id):
+ # Check if entry already exists
+ cursor.execute(
+ """SELECT id FROM artifacts
+ WHERE org_id = %s AND category = %s AND title = %s""",
+ (org_id, category, title.strip()),
+ )
+ existing = cursor.fetchone()
+
+ if existing and not overwrite:
+ return json.dumps({
+ "status": "already_exists",
+ "message": (
+ f"A memory entry '{title}' already exists in category '{category}'. "
+ "Use overwrite=true to replace it, or use append_to_memory / edit_memory "
+ "to modify the existing content."
+ ),
+ })
+
+ cursor.execute(
+ """INSERT INTO artifacts
+ (org_id, user_id, title, content, category, description,
+ last_edited_by, updated_at)
+ VALUES (%s, %s, %s, %s, %s, %s, 'agent', CURRENT_TIMESTAMP)
+ ON CONFLICT (org_id, category, title)
+ DO UPDATE SET content = EXCLUDED.content,
+ description = EXCLUDED.description,
+ user_id = EXCLUDED.user_id,
+ last_edited_by = 'agent',
+ updated_at = CURRENT_TIMESTAMP
+ RETURNING id""",
+ (org_id, user_id, title.strip(), content,
+ category, description.strip() if description else None),
+ )
+ artifact_id = str(cursor.fetchone()[0])
+
+ version = create_version(
+ cursor, artifact_id, org_id, user_id, content,
+ source="agent", session_id=session_id,
+ )
+ conn.commit()
+
+ logger.info(f"[MemoryTool] Wrote memory '{category}/{title.strip()}' v{version} for org {org_id}")
+ action = "overwritten" if existing else "created"
+ return json.dumps({
+ "status": "ok",
+ "action": action,
+ "message": f"Memory {action}: {category}/{title.strip()} (version {version}).",
+ "version": version,
+ })
+
+ except ValueError as e:
+ return _error_response(e)
+ except Exception:
+ logger.exception("[MemoryTool] Failed to write memory")
+ return json.dumps({"error": "Failed to write memory."})
+
+
+# ---------------------------------------------------------------------------
+# append_to_memory
+# ---------------------------------------------------------------------------
+
+
+class AppendToMemoryArgs(BaseModel):
+ category: str = Field(description=f"The memory category ({_CATEGORY_HELP})")
+ title: str = Field(description="The exact title of the memory entry to append to")
+ content: str = Field(description="Content to append at the end of the existing entry")
+ description: str = Field(
+ default="",
+ description="Updated one-line summary for the memory index. If provided, replaces the existing description to reflect the new content.",
+ )
+
+
+def append_to_memory(
+ category: str,
+ title: str,
+ content: str,
+ description: str = "",
+ user_id: str | None = None,
+ session_id: str | None = None,
+ **kwargs,
+) -> str:
+ """Append content to an existing memory entry. Creates the entry if it doesn't exist."""
+ if err := _validate_category(category):
+ return err
+ if err := _validate_title(title):
+ return err
+ if not content or not content.strip():
+ return json.dumps({"error": "content cannot be empty."})
+
+ content = strip_nul(content)
+
+ try:
+ with _memory_connection(user_id, "append") as (cursor, conn, org_id):
+ cursor.execute(
+ """SELECT id, content FROM artifacts
+ WHERE org_id = %s AND category = %s AND title = %s
+ FOR UPDATE""",
+ (org_id, category, title.strip()),
+ )
+ row = cursor.fetchone()
+
+ if row:
+ artifact_id = str(row[0])
+ existing = row[1] or ""
+ new_content = existing + "\n" + content if existing else content
+ else:
+ new_content = content
+ artifact_id = None
+
+ if len(new_content) > _AGENT_WRITE_LIMIT:
+ return json.dumps({"error": "Resulting content would exceed 500KB limit."})
+
+ if artifact_id:
+ # Update content and optionally the description
+ if description and description.strip():
+ cursor.execute(
+ """UPDATE artifacts
+ SET content = %s, description = %s, last_edited_by = 'agent', updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s""",
+ (new_content, description.strip(), artifact_id),
+ )
+ else:
+ cursor.execute(
+ """UPDATE artifacts
+ SET content = %s, last_edited_by = 'agent', updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s""",
+ (new_content, artifact_id),
+ )
+ else:
+ cursor.execute(
+ """INSERT INTO artifacts
+ (org_id, user_id, title, content, category, description,
+ last_edited_by, updated_at)
+ VALUES (%s, %s, %s, %s, %s, %s, 'agent', CURRENT_TIMESTAMP)
+ RETURNING id""",
+ (org_id, user_id, title.strip(), new_content, category,
+ (description.strip() if description else new_content[:150].strip())),
+ )
+ artifact_id = str(cursor.fetchone()[0])
+
+ version = create_version(
+ cursor, artifact_id, org_id, user_id, new_content,
+ source="agent", session_id=session_id,
+ )
+ conn.commit()
+
+ logger.info(f"[MemoryTool] Appended to '{category}/{title.strip()}' v{version}")
+ return json.dumps({
+ "status": "ok",
+ "message": f"Appended to {category}/{title.strip()} (version {version}).",
+ "version": version,
+ })
+
+ except ValueError as e:
+ return _error_response(e)
+ except Exception:
+ logger.exception("[MemoryTool] Failed to append to memory")
+ return json.dumps({"error": "Failed to append to memory."})
+
+
+# ---------------------------------------------------------------------------
+# edit_memory
+# ---------------------------------------------------------------------------
+
+
+class EditMemoryArgs(BaseModel):
+ category: str = Field(description=f"The memory category ({_CATEGORY_HELP})")
+ title: str = Field(description="The exact title of the memory entry to edit")
+ old_text: str = Field(description="The exact text to find in the entry (must match precisely)")
+ new_text: str = Field(description="The replacement text (use empty string to delete the matched section)")
+
+
+def edit_memory(
+ category: str,
+ title: str,
+ old_text: str,
+ new_text: str,
+ user_id: str | None = None,
+ session_id: str | None = None,
+ **kwargs,
+) -> str:
+ """Find and replace text within a memory entry. Like sed — surgical edits without rewriting."""
+ if err := _validate_category(category):
+ return err
+ if err := _validate_title(title):
+ return err
+ if not old_text:
+ return json.dumps({"error": "old_text is required (the text to find and replace)."})
+
+ new_text = strip_nul(new_text)
+ try:
+ with _memory_connection(user_id, "edit") as (cursor, conn, org_id):
+ cursor.execute(
+ """SELECT id, content FROM artifacts
+ WHERE org_id = %s AND category = %s AND title = %s
+ FOR UPDATE""",
+ (org_id, category, title.strip()),
+ )
+ row = cursor.fetchone()
+
+ if not row:
+ return json.dumps({
+ "status": "not_found",
+ "message": f"No memory entry '{title}' in category '{category}'.",
+ })
+
+ artifact_id = str(row[0])
+ existing = row[1] or ""
+
+ if old_text not in existing:
+ return json.dumps({
+ "status": "no_match",
+ "message": "old_text not found in the entry. Read the entry first to see its current content.",
+ })
+
+ # Reject ambiguous matches — agent must provide enough context for uniqueness
+ if existing.count(old_text) > 1:
+ return json.dumps({
+ "status": "ambiguous",
+ "message": f"old_text matches {existing.count(old_text)} locations. Include more surrounding context to make it unique.",
+ })
+
+ new_content = existing.replace(old_text, new_text, 1)
+
+ if len(new_content) > _AGENT_WRITE_LIMIT:
+ return json.dumps({"error": "Resulting content would exceed 500KB limit."})
+
+ cursor.execute(
+ """UPDATE artifacts
+ SET content = %s, last_edited_by = 'agent', updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s""",
+ (new_content, artifact_id),
+ )
+
+ version = create_version(
+ cursor, artifact_id, org_id, user_id, new_content,
+ source="agent", session_id=session_id,
+ )
+ conn.commit()
+
+ logger.info(f"[MemoryTool] Edited '{category}/{title.strip()}' v{version}")
+ return json.dumps({
+ "status": "ok",
+ "message": f"Edited {category}/{title.strip()} (version {version}).",
+ "version": version,
+ })
+
+ except ValueError as e:
+ return _error_response(e)
+ except Exception:
+ logger.exception("[MemoryTool] Failed to edit memory")
+ return json.dumps({"error": "Failed to edit memory."})
+
+
+# ---------------------------------------------------------------------------
+# grep_memories
+# ---------------------------------------------------------------------------
+
+
+class GrepMemoriesArgs(BaseModel):
+ query: str = Field(description="Search pattern — supports regex (e.g. 'redis.*timeout', '(5xx|500)'). Plain strings work too.")
+ category: str = Field(
+ default="",
+ description=(
+ f"Optionally limit search to a specific category: {_CATEGORY_HELP}. "
+ "Leave empty to search all."
+ ),
+ )
+
+
+def grep_memories(
+ query: str,
+ category: str = "",
+ user_id: str | None = None,
+ **kwargs,
+) -> str:
+ """Search across all memory content using regex patterns. Like grep -ri across all entries."""
+ if not query or not query.strip():
+ return json.dumps({"error": "query is required."})
+ if err := _validate_category(category, allow_empty=True):
+ return err
+
+ search_pattern = query.strip()
+
+ try:
+ with _memory_connection(user_id, "grep") as (cursor, conn, org_id):
+ # Use PostgreSQL regex (~* = case-insensitive regex match)
+ # Fall back to ILIKE if the pattern is invalid regex
+ try:
+ cursor.execute("SAVEPOINT regex_attempt")
+ if category:
+ cursor.execute(
+ """SELECT title, category, content FROM artifacts
+ WHERE org_id = %s AND category = %s
+ AND content ~* %s
+ ORDER BY updated_at DESC
+ LIMIT %s""",
+ (org_id, category, search_pattern, _GREP_MAX_RESULTS),
+ )
+ else:
+ cursor.execute(
+ """SELECT title, category, content FROM artifacts
+ WHERE org_id = %s AND category = ANY(%s)
+ AND content ~* %s
+ ORDER BY updated_at DESC
+ LIMIT %s""",
+ (org_id, list(MEMORY_CATEGORIES), search_pattern, _GREP_MAX_RESULTS),
+ )
+ rows = cursor.fetchall()
+ cursor.execute("RELEASE SAVEPOINT regex_attempt")
+ except Exception as regex_err:
+ # Invalid regex — roll back only the failed statement, preserving RLS context
+ cursor.execute("ROLLBACK TO SAVEPOINT regex_attempt")
+ logger.debug(f"[MemoryTool:grep] Regex failed, falling back to ILIKE: {regex_err}")
+ if category:
+ cursor.execute(
+ """SELECT title, category, content FROM artifacts
+ WHERE org_id = %s AND category = %s
+ AND content ILIKE %s
+ ORDER BY updated_at DESC
+ LIMIT %s""",
+ (org_id, category, f"%{search_pattern}%", _GREP_MAX_RESULTS),
+ )
+ else:
+ cursor.execute(
+ """SELECT title, category, content FROM artifacts
+ WHERE org_id = %s AND category = ANY(%s)
+ AND content ILIKE %s
+ ORDER BY updated_at DESC
+ LIMIT %s""",
+ (org_id, list(MEMORY_CATEGORIES), f"%{search_pattern}%", _GREP_MAX_RESULTS),
+ )
+ rows = cursor.fetchall()
+
+ if not rows:
+ return json.dumps({
+ "status": "ok",
+ "count": 0,
+ "matches": [],
+ "message": f"No matches for '{search_pattern}' in memory.",
+ })
+
+ matches = []
+ for row_title, row_cat, row_content in rows:
+ content = row_content or ""
+ # Find match position for snippet extraction
+ try:
+ match = re.search(search_pattern, content, re.IGNORECASE)
+ idx = match.start() if match else -1
+ match_len = (match.end() - match.start()) if match else len(search_pattern)
+ except re.error:
+ idx = content.lower().find(search_pattern.lower())
+ match_len = len(search_pattern)
+
+ if idx >= 0:
+ start = max(0, idx - _GREP_SNIPPET_CHARS // 2)
+ end = min(len(content), idx + match_len + _GREP_SNIPPET_CHARS // 2)
+ snippet = content[start:end]
+ if start > 0:
+ snippet = "..." + snippet
+ if end < len(content):
+ snippet = snippet + "..."
+ else:
+ snippet = content[:_GREP_SNIPPET_CHARS]
+
+ matches.append({
+ "title": row_title,
+ "category": row_cat,
+ "snippet": snippet,
+ })
+
+ return json.dumps({
+ "status": "ok",
+ "count": len(matches),
+ "matches": matches,
+ })
+
+ except ValueError as e:
+ return _error_response(e)
+ except Exception:
+ logger.exception("[MemoryTool] Failed to grep memories")
+ return json.dumps({"error": "Failed to search memories."})
+
+
+# ---------------------------------------------------------------------------
+# rename_memory
+# ---------------------------------------------------------------------------
+
+
+class RenameMemoryArgs(BaseModel):
+ category: str = Field(description=f"The memory category ({_CATEGORY_HELP})")
+ title: str = Field(description="The current exact title of the memory entry")
+ new_title: str = Field(default="", description="New title for the entry (leave empty to keep current title)")
+ new_description: str = Field(default="", description="New one-line summary for the memory index (leave empty to keep current)")
+
+
+def rename_memory(
+ category: str,
+ title: str,
+ new_title: str = "",
+ new_description: str = "",
+ user_id: str | None = None,
+ **kwargs,
+) -> str:
+ """Rename a memory entry's title and/or update its description without touching content."""
+ if err := _validate_category(category):
+ return err
+ if err := _validate_title(title):
+ return err
+ if new_title and (err := _validate_title(new_title)):
+ return err
+ if not new_title and not new_description:
+ return json.dumps({"error": "Provide at least one of new_title or new_description."})
+
+ try:
+ with _memory_connection(user_id, "rename") as (cursor, conn, org_id):
+ sets, vals = [], []
+ if new_title and new_title.strip():
+ sets.append("title = %s")
+ vals.append(new_title.strip())
+ if new_description and new_description.strip():
+ sets.append("description = %s")
+ vals.append(new_description.strip())
+ sets.append("updated_at = CURRENT_TIMESTAMP")
+
+ vals.extend([org_id, category, title.strip()])
+ cursor.execute(
+ f"""UPDATE artifacts SET {', '.join(sets)}
+ WHERE org_id = %s AND category = %s AND title = %s
+ RETURNING id""",
+ vals,
+ )
+ row = cursor.fetchone()
+ conn.commit()
+
+ if not row:
+ return json.dumps({
+ "status": "not_found",
+ "message": f"No memory entry '{title}' in category '{category}'.",
+ })
+
+ final_title = new_title.strip() if new_title else title.strip()
+ logger.info(f"[MemoryTool] Renamed '{category}/{title.strip()}' → '{final_title}'")
+ return json.dumps({
+ "status": "ok",
+ "message": f"Renamed to {category}/{final_title}.",
+ })
+
+ except ValueError as e:
+ return _error_response(e)
+ except Exception:
+ logger.exception("[MemoryTool] Failed to rename memory")
+ return json.dumps({"error": "Failed to rename memory."})
+
+
+# ---------------------------------------------------------------------------
+# delete_memory
+# ---------------------------------------------------------------------------
+
+
+class DeleteMemoryArgs(BaseModel):
+ category: str = Field(description=f"The memory category ({_CATEGORY_HELP})")
+ title: str = Field(description="The exact title of the memory entry to delete")
+
+
+def delete_memory(
+ category: str,
+ title: str,
+ user_id: str | None = None,
+ **kwargs,
+) -> str:
+ """Permanently delete a memory entry."""
+ if err := _validate_category(category):
+ return err
+ if err := _validate_title(title):
+ return err
+
+ try:
+ with _memory_connection(user_id, "delete") as (cursor, conn, org_id):
+ cursor.execute(
+ """DELETE FROM artifacts
+ WHERE org_id = %s AND category = %s AND title = %s
+ RETURNING id""",
+ (org_id, category, title.strip()),
+ )
+ deleted = cursor.fetchone()
+ conn.commit()
+
+ if not deleted:
+ return json.dumps({
+ "status": "not_found",
+ "message": f"No memory entry '{title}' in category '{category}'.",
+ })
+
+ logger.info(f"[MemoryTool] Deleted '{category}/{title.strip()}' for org")
+ return json.dumps({
+ "status": "ok",
+ "message": f"Deleted {category}/{title.strip()}.",
+ })
+
+ except ValueError as e:
+ return _error_response(e)
+ except Exception:
+ logger.exception("[MemoryTool] Failed to delete memory")
+ return json.dumps({"error": "Failed to delete memory."})
+
+
+# ---------------------------------------------------------------------------
+# Shared tool builder — used by Celery background agents (collector, consolidation)
+# ---------------------------------------------------------------------------
+
+
+def build_memory_tools(user_id: str, session_id: str | None = None) -> list:
+ """Build LangChain StructuredTools scoped to a user for background agents.
+
+ Binds user_id/session_id via closures since there's no Flask request context
+ in Celery. Returns all memory tools — the prompt guides which ones the agent uses.
+ """
+ from langchain_core.tools import StructuredTool
+
+ def _list(category: str = "") -> str:
+ return list_memories(category=category, user_id=user_id)
+
+ def _read(category: str, title: str, offset: int = 0, limit: int = 50000) -> str:
+ return read_memory(category=category, title=title, offset=offset, limit=limit, user_id=user_id)
+
+ def _write(category: str, title: str, content: str, description: str = "", overwrite: bool = False) -> str:
+ return write_memory(
+ category=category, title=title, content=content,
+ description=description, overwrite=overwrite,
+ user_id=user_id, session_id=session_id,
+ )
+
+ def _append(category: str, title: str, content: str, description: str = "") -> str:
+ return append_to_memory(
+ category=category, title=title, content=content,
+ description=description,
+ user_id=user_id, session_id=session_id,
+ )
+
+ def _edit(category: str, title: str, old_text: str, new_text: str) -> str:
+ return edit_memory(
+ category=category, title=title, old_text=old_text, new_text=new_text,
+ user_id=user_id, session_id=session_id,
+ )
+
+ def _delete(category: str, title: str) -> str:
+ return delete_memory(category=category, title=title, user_id=user_id)
+
+ def _rename(category: str, title: str, new_title: str = "", new_description: str = "") -> str:
+ return rename_memory(
+ category=category, title=title, new_title=new_title, new_description=new_description,
+ user_id=user_id,
+ )
+
+ return [
+ StructuredTool.from_function(
+ func=_list,
+ name="list_memories",
+ description="List org memory entries, optionally filtered by category.",
+ args_schema=ListMemoriesArgs,
+ ),
+ StructuredTool.from_function(
+ func=_read,
+ name="read_memory",
+ description="Read the full content of a specific memory entry by category and title.",
+ args_schema=ReadMemoryArgs,
+ ),
+ StructuredTool.from_function(
+ func=_write,
+ name="write_memory",
+ description="Create or overwrite a memory entry. Use overwrite=true for rewrites and merge targets.",
+ args_schema=WriteMemoryArgs,
+ ),
+ StructuredTool.from_function(
+ func=_append,
+ name="append_to_memory",
+ description="Append content to an existing memory entry. Creates it if it doesn't exist. Pass description to update the index summary.",
+ args_schema=AppendToMemoryArgs,
+ ),
+ StructuredTool.from_function(
+ func=_edit,
+ name="edit_memory",
+ description="Find-and-replace within a memory entry. Use for surgical fixes without rewriting the whole entry.",
+ args_schema=EditMemoryArgs,
+ ),
+ StructuredTool.from_function(
+ func=_rename,
+ name="rename_memory",
+ description="Rename a memory entry's title and/or update its index description without touching content.",
+ args_schema=RenameMemoryArgs,
+ ),
+ StructuredTool.from_function(
+ func=_delete,
+ name="delete_memory",
+ description="Permanently delete a memory entry. Use after merging (delete the source) or for stale entries.",
+ args_schema=DeleteMemoryArgs,
+ ),
+ ]
diff --git a/server/chat/backend/agent/tools/notion/postmortem.py b/server/chat/backend/agent/tools/notion/postmortem.py
index 583979be0..819fa324c 100644
--- a/server/chat/backend/agent/tools/notion/postmortem.py
+++ b/server/chat/backend/agent/tools/notion/postmortem.py
@@ -139,8 +139,8 @@ def _fetch_postmortem(
cursor.execute("SET myapp.current_org_id = %s", (org_id,))
conn.commit()
cursor.execute(
- """SELECT id, content FROM postmortems
- WHERE incident_id = %s AND org_id = %s""",
+ """SELECT id, content FROM artifacts
+ WHERE incident_id = %s AND org_id = %s AND category = 'postmortem'""",
(incident_id, org_id),
)
row = cursor.fetchone()
@@ -213,7 +213,6 @@ def _update_postmortem_notion_metadata(
cursor.execute("SET myapp.current_user_id = %s", (user_id,))
cursor.execute("SET myapp.current_org_id = %s", (org_id,))
conn.commit()
- # Write to normalized exports table (upsert)
cursor.execute(
"""INSERT INTO postmortem_exports
(postmortem_id, org_id, destination, external_id, external_url, external_database_id, exported_at)
@@ -225,16 +224,6 @@ def _update_postmortem_notion_metadata(
exported_at = EXCLUDED.exported_at""",
(str(postmortem_id), org_id, str(page_id), page_url, str(database_id)),
)
- # Keep legacy columns in sync for backwards compatibility
- cursor.execute(
- """UPDATE postmortems
- SET notion_page_id = %s,
- notion_page_url = %s,
- notion_exported_at = CURRENT_TIMESTAMP,
- notion_database_id = %s
- WHERE id = %s AND org_id = %s""",
- (str(page_id), page_url, str(database_id), str(postmortem_id), org_id),
- )
conn.commit()
except Exception as exc:
logger.warning(
diff --git a/server/chat/backend/agent/tools/postmortem_tool.py b/server/chat/backend/agent/tools/postmortem_tool.py
index b5ab4bc2e..e4357e7c0 100644
--- a/server/chat/backend/agent/tools/postmortem_tool.py
+++ b/server/chat/backend/agent/tools/postmortem_tool.py
@@ -2,8 +2,8 @@
Postmortem Tools
Agent-callable tools for reading and writing postmortem documents.
-Used by the built-in "Generate Postmortem" action and available
-during regular chat for postmortem-related queries.
+Postmortems are stored as artifacts with category='postmortem' and an
+incident_id linking them to the originating incident.
"""
import json
@@ -11,7 +11,10 @@
from pydantic import BaseModel, Field
-from utils.validation import is_valid_uuid
+from utils.validation import is_valid_uuid, strip_nul
+from utils.db.connection_pool import db_pool
+from utils.auth.stateless_auth import set_rls_context
+from services.artifacts.store import create_version
logger = logging.getLogger(__name__)
@@ -52,16 +55,13 @@ def get_postmortem(
})
try:
- from utils.db.connection_pool import db_pool
- from utils.auth.stateless_auth import set_rls_context
-
with db_pool.get_admin_connection() as conn:
with conn.cursor() as cursor:
set_rls_context(cursor, conn, user_id, log_prefix="[PostmortemTool]")
cursor.execute(
- """SELECT content, generated_at, updated_at
- FROM postmortems
- WHERE incident_id = %s""",
+ """SELECT content, created_at, updated_at
+ FROM artifacts
+ WHERE incident_id = %s AND category = 'postmortem'""",
(incident_id,),
)
row = cursor.fetchone()
@@ -119,66 +119,62 @@ def save_postmortem(
if len(content) > 100000:
return json.dumps({"error": "Content exceeds maximum length (100000 chars)."})
- try:
- from utils.db.connection_pool import db_pool
- from utils.auth.stateless_auth import set_rls_context
+ content = strip_nul(content)
+ try:
with db_pool.get_admin_connection() as conn:
with conn.cursor() as cursor:
set_rls_context(cursor, conn, user_id, log_prefix="[PostmortemTool:save]")
- # Resolve org_id from the incident (under RLS) to prevent cross-tenant writes
- cursor.execute("SELECT org_id FROM incidents WHERE id = %s", (incident_id,))
+ # Resolve org_id and alert_title from the incident
+ cursor.execute(
+ "SELECT org_id, alert_title FROM incidents WHERE id = %s",
+ (incident_id,),
+ )
incident_row = cursor.fetchone()
- org_id = incident_row[0] if incident_row else None
-
- if not org_id:
+ if not incident_row:
return json.dumps({"error": "Incident not found or not accessible."})
- # Upsert the postmortem
+ org_id = incident_row[0]
+ alert_title = incident_row[1]
+ short_id = str(incident_id)[:8]
+ title = f"Postmortem: {alert_title} [{short_id}]" if alert_title else f"Postmortem ({incident_id})"
+
+ # Upsert the artifact with incident_id linkage
cursor.execute(
- """INSERT INTO postmortems (incident_id, user_id, org_id, content,
- generation_session_id, generated_at, updated_at)
- VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
- ON CONFLICT (incident_id)
+ """INSERT INTO artifacts
+ (org_id, user_id, title, content, category, description,
+ incident_id, generation_session_id, last_edited_by, updated_at)
+ VALUES (%s, %s, %s, %s, 'postmortem', %s,
+ %s, %s, 'agent', CURRENT_TIMESTAMP)
+ ON CONFLICT (incident_id) WHERE incident_id IS NOT NULL
DO UPDATE SET content = EXCLUDED.content,
+ title = EXCLUDED.title,
+ description = EXCLUDED.description,
generation_session_id = EXCLUDED.generation_session_id,
+ last_edited_by = 'agent',
updated_at = CURRENT_TIMESTAMP
RETURNING id""",
- (incident_id, user_id, org_id, content, session_id),
+ (org_id, user_id, title, content,
+ f"Postmortem for incident: {alert_title or incident_id}",
+ incident_id, session_id),
)
- postmortem_row = cursor.fetchone()
- if not postmortem_row:
+ row = cursor.fetchone()
+ if not row:
conn.rollback()
return json.dumps({"error": "Failed to save postmortem — access denied or conflict."})
- postmortem_id = str(postmortem_row[0])
+ artifact_id = str(row[0])
- # Create a version row (atomic with a subquery to prevent race conditions)
- cursor.execute(
- """INSERT INTO postmortem_versions
- (postmortem_id, org_id, user_id, content, version_number, source, generation_session_id)
- VALUES (%s, %s, %s, %s,
- (SELECT COALESCE(MAX(version_number), 0) + 1
- FROM postmortem_versions WHERE postmortem_id = %s),
- %s, %s)
- RETURNING id, version_number""",
- (postmortem_id, org_id, user_id, content, postmortem_id, "agent", session_id),
+ version = create_version(
+ cursor, artifact_id, org_id, user_id, content,
+ source="agent", session_id=session_id,
)
- version_row = cursor.fetchone()
- version_id, next_version = str(version_row[0]), version_row[1]
-
- # Update the current version pointer
- cursor.execute(
- "UPDATE postmortems SET current_version_id = %s WHERE id = %s",
- (version_id, postmortem_id),
- )
-
conn.commit()
return json.dumps({
"status": "ok",
- "message": f"Postmortem saved (version {next_version}).",
- "version": next_version,
+ "message": f"Postmortem saved (version {version}).",
+ "version": version,
})
except Exception:
diff --git a/server/chat/backend/agent/tools/rag_indexer_tool.py b/server/chat/backend/agent/tools/rag_indexer_tool.py
deleted file mode 100644
index 84005c240..000000000
--- a/server/chat/backend/agent/tools/rag_indexer_tool.py
+++ /dev/null
@@ -1,196 +0,0 @@
-import os
-import io
-import zipfile
-import logging
-from typing import List, Optional, Dict, Any, Tuple
-
-from pydantic import BaseModel, Field
-from chat.backend.agent.weaviate_client import WeaviateClient
-from chat.backend.agent.db import PostgreSQLClient
-
-logger = logging.getLogger(__name__)
-
-
-class RAGIndexZipArgs(BaseModel):
- attachment_index: int = Field(0, description="Index of the ZIP attachment to index")
- max_files: int = Field(200, description="Safety cap on number of files to index")
- max_file_bytes: int = Field(750_000, description="Max bytes per file to index")
- include_patterns: Optional[List[str]] = Field(
- default=[".md", ".txt", ".py", ".js", ".ts", ".tsx", ".json", ".yaml", ".yml", ".sh", ".tf"],
- description="File extensions to include"
- )
- exclude_dirs: Optional[List[str]] = Field(
- default=["node_modules", ".git", "__pycache__", "dist", "build", "__MACOSX"],
- description="Directory names to skip"
- )
-
-
-def _should_index_file(path: str, include_exts: List[str], exclude_dirs: List[str]) -> bool:
- norm = path.strip()
- parts = norm.split("/")
- for d in exclude_dirs:
- if d in parts:
- return False
- _, ext = os.path.splitext(norm.lower())
- return (ext in include_exts) and not norm.endswith("/")
-
-
-def _chunk_text(text: str, chunk_size: int = 1200, overlap: int = 200) -> List[str]:
- if not text:
- return []
- chunks = []
- i = 0
- n = len(text)
- while i < n:
- end = min(n, i + chunk_size)
- chunks.append(text[i:end])
- i = end - overlap if end - overlap > i else end
- return chunks
-
-
-def rag_index_zip(attachment_index: int = 0, max_files: int = 200, max_file_bytes: int = 750_000,
- include_patterns: Optional[List[str]] = None, exclude_dirs: Optional[List[str]] = None,
- user_id: Optional[str] = None, session_id: Optional[str] = None) -> str:
- """Index a ZIP attachment's text/code files into Weaviate for RAG.
-
- - Reads attachment from state (supports server_path or storage URI).
- - Filters by extension and excluded directories.
- - Chunks text and upserts with minimal metadata: user_id hash, session_id, filename, path.
- """
- try:
- # Lazy import to avoid circular import with cloud_tools during module initialization
- from .cloud_tools import get_state_context, send_tool_start, send_tool_completion
-
- state = get_state_context()
- if not state or not getattr(state, 'attachments', None):
- return "No attachments found in state. Please upload a ZIP."
-
- if attachment_index < 0 or attachment_index >= len(state.attachments):
- return f"Invalid attachment_index {attachment_index}."
-
- attachment = state.attachments[attachment_index]
- server_path = attachment.get('server_path') or attachment.get('path') or attachment.get('storage_uri')
- filename = attachment.get('filename', 'archive.zip')
-
- if not server_path:
- return "Attachment does not include a server_path or storage URI."
-
- # Generate consistent tool_call_id for start/completion matching
- import hashlib
- import json
- tool_input_data = {"attachment_index": attachment_index, "filename": filename}
- # Use JSON serialization with sorted keys for deterministic hashing
- signature = f"rag_index_zip_{json.dumps(tool_input_data, sort_keys=True, default=str)}"
- # Use longer hash (16 chars) to reduce collision risk
- signature_hash = hashlib.sha256(signature.encode()).hexdigest()[:16]
- tool_call_id = f"rag_index_zip_{signature_hash}"
-
- send_tool_start("rag_index_zip", tool_input_data, tool_call_id)
-
- # Open the zip locally (download from storage if needed)
- local_path = server_path
- temp_to_delete = None
- if server_path.startswith('s3://'):
- from utils.storage.storage import download_zip_from_storage
- local_path, _ = download_zip_from_storage(server_path, user_id=user_id)
- temp_to_delete = local_path
-
- include_exts = include_patterns or [".md", ".txt", ".py", ".js", ".ts", ".tsx", ".json", ".yaml", ".yml", ".sh", ".tf"]
- skip_dirs = exclude_dirs or ["node_modules", ".git", "__pycache__", "dist", "build", "__MACOSX"]
-
- # Collect files and prepare chunks
- indexed_files = 0
- indexed_chunks = 0
- errors = 0
- vectors: List[Tuple[str, Dict[str, Any]]] = []
-
- with zipfile.ZipFile(local_path, 'r') as zip_ref:
- for info in zip_ref.infolist():
- # Respect caps
- if indexed_files >= max_files:
- break
- path = info.filename
- if not _should_index_file(path, include_exts, skip_dirs):
- continue
- try:
- data = zip_ref.read(info)
- if len(data) > max_file_bytes:
- continue
- try:
- text = data.decode('utf-8')
- except UnicodeDecodeError:
- try:
- text = data.decode('latin-1')
- except Exception:
- continue
- chunks = _chunk_text(text)
- if not chunks:
- continue
- # Metadata
- meta = {
- "filename": os.path.basename(path),
- "path": path,
- "archive": filename,
- "session_id": session_id or getattr(state, 'session_id', None),
- "user_id": user_id or getattr(state, 'user_id', None),
- "org_id": getattr(state, 'org_id', None) or "",
- }
- for c in chunks:
- vectors.append((c, meta))
- indexed_files += 1
- indexed_chunks += len(chunks)
- except Exception as e:
- errors += 1
- logger.warning(f"Failed to index file {path}: {e}")
-
- # Upsert into Weaviate
- if vectors:
- try:
- pg = PostgreSQLClient()
- wv = WeaviateClient(pg)
- # Use a simple collection for RAG documents if not present
- # We'll create a generic 'RAGDoc' class on demand using weaviate client directly
- client = wv.client
- if not client.collections.exists("RAGDoc"):
- from weaviate.classes.config import Configure, Property, DataType
- client.collections.create(
- name="RAGDoc",
- vectorizer_config=Configure.Vectorizer.text2vec_transformers(),
- properties=[
- Property(name="text", data_type=DataType.TEXT),
- Property(name="filename", data_type=DataType.TEXT),
- Property(name="path", data_type=DataType.TEXT),
- Property(name="archive", data_type=DataType.TEXT),
- Property(name="session_id", data_type=DataType.TEXT),
- Property(name="user_id", data_type=DataType.TEXT),
- Property(name="org_id", data_type=DataType.TEXT),
- ],
- )
- coll = client.collections.get("RAGDoc")
- import uuid as _uuid
- # Insert in batches
- batch = coll.batch.dynamic()
- for text, meta in vectors:
- batch.add_object(properties={"text": text, **meta}, uuid=_uuid.uuid4())
- batch.flush()
- except Exception as e:
- errors += 1
- logger.error(f"Weaviate upsert failed: {e}")
-
- if temp_to_delete and os.path.exists(temp_to_delete):
- try:
- os.remove(temp_to_delete)
- except Exception:
- pass
-
- out = (
- f"Indexed {indexed_files} files and {indexed_chunks} chunks from {filename} into RAG."
- )
- if errors:
- out += f" Encountered {errors} errors."
- send_tool_completion("rag_index_zip", out, "completed", tool_call_id)
- return out
-
- except Exception as e:
- logger.error(f"Error in rag_index_zip: {e}")
- return f"Error indexing ZIP: {str(e)}"
\ No newline at end of file
diff --git a/server/chat/backend/agent/weaviate_client.py b/server/chat/backend/agent/weaviate_client.py
deleted file mode 100644
index d111abf4d..000000000
--- a/server/chat/backend/agent/weaviate_client.py
+++ /dev/null
@@ -1,51 +0,0 @@
-import json
-from typing import Dict, List, Tuple
-import re
-import weaviate
-from weaviate.classes.query import Filter
-from weaviate.util import generate_uuid5
-import os
-from dotenv import load_dotenv
-from weaviate.classes.config import Configure, Property, DataType
-import logging
-
-from chat.backend.agent.db import PostgreSQLClient
-
-load_dotenv()
-
-logging.basicConfig(level=logging.DEBUG)
-logger = logging.getLogger(__name__)
-
-class WeaviateClient:
- def __init__(self, postgres_client: PostgreSQLClient):
- self.postgres_client = postgres_client
-
- openai_api_key = os.getenv("OPENAI_API_KEY", "").strip()
-
- headers = {}
- if openai_api_key:
- headers["X-OpenAI-Api-Key"] = openai_api_key
-
- WEAVIATE_HOST = os.getenv("WEAVIATE_HOST", "weaviate.default.svc.cluster.local")
- weaviate_secure = os.getenv("WEAVIATE_SECURE", "false").lower() in ("1", "true", "yes")
-
- self.client = weaviate.connect_to_custom(
- http_host=WEAVIATE_HOST,
- http_port=int(os.getenv("WEAVIATE_PORT", "8080")),
- http_secure=weaviate_secure,
- grpc_host=WEAVIATE_HOST,
- grpc_port=int(os.getenv("WEAVIATE_GRPC_PORT", "50051")),
- grpc_secure=weaviate_secure,
- headers=headers,
- )
-
- assert self.client.is_ready(), "Weaviate client is not ready. Check the connection."
-
- def is_connected(self) -> bool:
- """Check if the Weaviate client is connected."""
- return self.client.is_connected()
-
- def close(self) -> None:
- """Close the Weaviate client connection."""
- if self.client.is_connected():
- self.client.close()
diff --git a/server/chat/backend/agent/workflow.py b/server/chat/backend/agent/workflow.py
index c2f0ba173..d9c46436e 100644
--- a/server/chat/backend/agent/workflow.py
+++ b/server/chat/backend/agent/workflow.py
@@ -1115,6 +1115,13 @@ async def stream(self, input_state: State):
if _event_count <= 5:
logger.info(f"[WORKFLOW STREAM] Event #{_event_count}: type={event_type}, name={event_name}")
+ # Internal utility LLM calls (memory selector, etc.) run inside the
+ # graph's async context but must never stream tokens to the user.
+ if event_type in ("on_chat_model_start", "on_chat_model_stream", "on_chat_model_end"):
+ _ev_run_name = (event.get("metadata") or {}).get("run_name") or event.get("name", "")
+ if _ev_run_name == "memory_selector":
+ continue
+
# Reset per-turn token counter when a new model call starts
if event_type == "on_chat_model_start":
_model_turn_tokens = 0
@@ -1349,6 +1356,16 @@ async def stream(self, input_state: State):
else:
logger.warning(f"[WORKFLOW FINAL] Failed to save final context for session {session_id}")
+ # Save durable learnings from this conversation to org memory (non-blocking)
+ try:
+ from services.memory.collector import extract_memories_from_session
+ extract_memories_from_session.delay(
+ session_id=session_id,
+ user_id=user_id,
+ )
+ except Exception:
+ logger.debug("[WORKFLOW FINAL] Memory save enqueue failed")
+
# Append only this turn's new messages to the UI column so RCA
# compression (or any future state rewrite) can't truncate the
# persisted chat history.
diff --git a/server/chat/background/citation_extractor.py b/server/chat/background/citation_extractor.py
index 2593c78fb..738029bc2 100644
--- a/server/chat/background/citation_extractor.py
+++ b/server/chat/background/citation_extractor.py
@@ -37,9 +37,12 @@
"cloudbees_rca": "CloudBees RCA",
"tailscale_ssh": "Tailscale SSH",
"analyze_zip_file": "ZIP Analyzer",
- "rag_index_zip": "RAG Indexer",
- "knowledge_base_search": "Knowledge Base",
- "save_discovery_finding": "Discovery Finding",
+ "list_memories": "Memory",
+ "read_memory": "Memory",
+ "write_memory": "Memory",
+ "append_to_memory": "Memory",
+ "edit_memory": "Memory",
+ "grep_memories": "Memory",
"search_splunk": "Splunk Search",
"list_splunk_indexes": "Splunk Indexes",
"list_splunk_sourcetypes": "Splunk Sourcetypes",
diff --git a/server/chat/background/prediscovery_task.py b/server/chat/background/prediscovery_task.py
index 12dd4f75a..9ef55c7ae 100644
--- a/server/chat/background/prediscovery_task.py
+++ b/server/chat/background/prediscovery_task.py
@@ -3,7 +3,7 @@
The prediscovery agent autonomously explores connected integrations (GitHub, Jenkins,
cloud providers, monitoring tools) to map how services are interconnected. Findings
-are saved to the knowledge base for fast retrieval during RCA investigations.
+are saved to org memory for fast retrieval during RCA investigations.
"""
import logging
@@ -54,20 +54,6 @@ def _get_users_with_integrations() -> List[Dict[str, Any]]:
return []
-def _cleanup_old_discovery_chunks(org_id: str, before: str = None) -> int:
- """Delete previous discovery findings from Weaviate for this org.
-
- Args:
- org_id: Organization to clean up for
- before: ISO timestamp -- only delete findings created before this time
- """
- try:
- from routes.knowledge_base.weaviate_client import delete_discovery_chunks
- return delete_discovery_chunks(org_id, before=before)
- except Exception as e:
- logger.warning(f"[Prediscovery] Failed to cleanup old chunks: {e}")
- return 0
-
def build_prediscovery_prompt(user_id: str, providers: List[str], integrations: Dict[str, bool]) -> str:
"""Build the system prompt for the prediscovery agent."""
@@ -88,8 +74,7 @@ def build_prediscovery_prompt(user_id: str, providers: List[str], integrations:
## YOUR DELIVERABLE
-At the END of your investigation, you MUST call save_infrastructure_context() with a single
-document that covers:
+At the END of your investigation, produce a comprehensive infrastructure context document covering:
- All environments (for example production, staging, dev are common) and how they relate
- Services in each environment, their dependencies, and how they communicate
- CI/CD pipelines: what repo deploys where, through what mechanism
@@ -98,21 +83,28 @@ def build_prediscovery_prompt(user_id: str, providers: List[str], integrations:
- Network topology and security boundaries
- Any other interconnected systems
+BEFORE writing, read the existing 'Infrastructure Context' entry (if any) with read_memory(category='infrastructure', title='Infrastructure Context').
+- If it doesn't exist: use write_memory() to create it.
+- If it exists but is outdated or missing sections: use edit_memory() or append_to_memory() to update it.
+- If it exists and needs a full rewrite (e.g. major topology changes): use write_memory(overwrite=true).
+
Write it so a coding agent can understand the full system topology in one read. If an org doesn't have all of these don't make up findings, only write about things actually there. Use markdown
with clear sections. Be specific -- include names, regions, namespaces, ports, image tags.
## SIDE-EFFECT: INDIVIDUAL FINDINGS
-As you investigate, ALSO call save_discovery_finding() for each logical chain you discover.
-These are indexed separately for semantic search during incident response.
+As you investigate, save each logical chain you discover to memory (category='infrastructure').
+Before writing, check if a relevant entry already exists (use list_memories or grep_memories).
+- If an entry for that topic exists: use append_to_memory() or edit_memory() to update it.
+- If it's a new topic: use write_memory() to create a new entry.
## CRITICAL RULES
- The local filesystem is Aurora's own code -- NEVER use terminal_exec to read local files (ls, cat, find, grep, env). There is nothing useful locally.
- terminal_exec is ONLY allowed for SSH into manual VMs (e.g. ssh -i ~/.ssh/id_key user@ip).
- Each finding must describe real infrastructure you discovered by querying external APIs.
-- Call save_discovery_finding after EVERY interconnection chain you discover.
-- Call save_infrastructure_context ONCE at the very end with the complete consolidated document.
+- Save findings to memory as you go (check existing entries first, prefer append/edit over creating duplicates).
+- At the very end, update the 'Infrastructure Context' entry as described in YOUR DELIVERABLE above.
## EXPLORATION STRATEGY
@@ -160,17 +152,21 @@ def build_prediscovery_prompt(user_id: str, providers: List[str], integrations:
an incident and immediately understand the topology. Write full sentences, not bullet lists.
GOOD example:
- save_discovery_finding(
+ write_memory(
+ category='infrastructure',
title='payment-api deployment and monitoring chain',
content='The payment-api service lives in GitHub repo acme-org/payment-api on the main branch. It is deployed via Jenkins job payment-service-deploy which builds a Docker image pushed to ECR at 390403884122.dkr.ecr.us-east-1.amazonaws.com/payment-api. The deployment targets Kubernetes cluster prod-east-1 in namespace payments, running as deployment payment-api with 3 replicas. The service depends on RDS instance db-payments-prod (PostgreSQL) which it connects to via environment variable DATABASE_URL, and ElastiCache cluster redis-sessions for session storage. It is monitored by Datadog monitors payment-api-latency (threshold: p99 > 500ms) and payment-api-error-rate (threshold: 5xx > 1%), both tagged with service:payment-api and env:production.',
- tags='github,jenkins,k8s,aws,datadog,payment-api'
+ description='github,jenkins,k8s,aws,datadog,payment-api'
)
## BEGIN EXPLORATION
-Investigate all connected integrations. Build toward the consolidated document.
-Call save_discovery_finding() as you go. Once your investigation is complete, call
-save_infrastructure_context() with the full synthesized document."""
+IMPORTANT: Before investigating, use list_memories(category='infrastructure') to see what is already
+documented. READ existing entries to understand what you already know. Then investigate and ONLY save
+genuinely new or changed information.
+
+For new topics, use write_memory(). For adding findings to an existing entry, use append_to_memory().
+For correcting outdated details, use edit_memory(). Do not rewrite unchanged content."""
@celery_app.task(
@@ -202,7 +198,6 @@ def run_prediscovery(
logger.info(f"[Prediscovery] Starting for user {user_id} (trigger={trigger})")
from utils.auth.stateless_auth import get_org_id_for_user
- from datetime import datetime, timezone
org_id = get_org_id_for_user(user_id)
# Hook: check if LLM call is allowed
@@ -220,7 +215,6 @@ def run_prediscovery(
if connected_count == 0:
logger.info(f"[Prediscovery] No integrations for user {user_id}, skipping")
return {"status": "skipped", "reason": "no_integrations"}
- run_started_at = datetime.now(timezone.utc).isoformat()
prompt = build_prediscovery_prompt(user_id, providers, integrations)
@@ -245,9 +239,6 @@ def run_prediscovery(
from chat.background.task import _update_session_status
_update_session_status(session_id, "completed", user_id=user_id)
- if org_id:
- _cleanup_old_discovery_chunks(org_id, before=run_started_at)
-
logger.info(f"[Prediscovery] Completed for user {user_id}, session {session_id}")
return {"status": "completed", "session_id": session_id, "user_id": user_id}
diff --git a/server/chat/background/rca_prompt_builder.py b/server/chat/background/rca_prompt_builder.py
index 104d11d2d..f456ba1fb 100644
--- a/server/chat/background/rca_prompt_builder.py
+++ b/server/chat/background/rca_prompt_builder.py
@@ -6,9 +6,8 @@
It passes the raw payload directly to the LLM, with conditional truncation
for large payloads.
-Aurora Learn Integration:
-- When Aurora Learn is enabled, searches for similar past incidents with positive feedback
-- Injects context from helpful RCAs to improve new investigations
+The agent has access to org memory (learned entries from past RCAs, infrastructure
+topology, runbooks) via its memory tools — no explicit injection needed here.
"""
from typing import Any, Dict, List, Optional
@@ -41,229 +40,6 @@ def build_alert_rail_text(alert_details: Dict[str, Any]) -> str:
return "\n\n".join(parts)
-# ============================================================================
-# Aurora Learn - Similar RCA Context Injection
-# ============================================================================
-
-
-def _is_aurora_learn_enabled(user_id: str) -> bool:
- """Check if Aurora Learn is enabled for a user. Defaults to True."""
- if not user_id:
- return False
- try:
- from utils.auth.stateless_auth import get_user_preference
- setting = get_user_preference(user_id, "aurora_learn_enabled", default=True)
- return setting is True
- except Exception as e:
- logger.warning(f"Error checking Aurora Learn setting: {e}")
- return True # Default to enabled
-
-
-def inject_aurora_learn_context(
- prompt_parts: list,
- user_id: Optional[str],
- alert_title: str,
- alert_service: str,
- source_type: str,
-) -> None:
- """
- Append Aurora Learn context to prompt_parts if similar RCAs are found.
-
- This is a convenience wrapper for connector modules to inject Aurora Learn
- context into their RCA prompts without duplicating the try/except pattern.
-
- Args:
- prompt_parts: List of prompt strings to append to (modified in place)
- user_id: User ID for Aurora Learn lookup
- alert_title: Title of the alert
- alert_service: Service associated with the alert
- source_type: Source type (grafana, datadog, etc.)
- """
- if not user_id:
- return
-
- similar_context = _get_similar_good_rcas_context(
- user_id=user_id,
- alert_title=alert_title,
- alert_service=alert_service,
- source_type=source_type,
- )
- if similar_context:
- prompt_parts.append(similar_context)
-
-
-def _get_similar_good_rcas_context(
- user_id: str,
- alert_title: str,
- alert_service: str,
- source_type: str,
-) -> str:
- """
- Check if Aurora Learn is enabled and search for similar good RCAs.
-
- Returns formatted context string if matches found, empty string otherwise.
- """
- if not user_id:
- return ""
-
- # Check if Aurora Learn is enabled
- if not _is_aurora_learn_enabled(user_id):
- logger.debug(f"Aurora Learn disabled for user {user_id}, skipping context injection")
- return ""
-
- try:
- from routes.incident_feedback.weaviate_client import search_similar_good_rcas
-
- # Search for similar incidents with positive feedback
- matches = search_similar_good_rcas(
- user_id=user_id,
- alert_title=alert_title,
- alert_service=alert_service,
- source_type=source_type,
- limit=2,
- min_score=0.7,
- )
-
- if not matches:
- logger.debug(f"No similar good RCAs found for alert: {alert_title[:50]}...")
- return ""
-
- # Format matches for injection
- context_parts = [
- "",
- "## CONTEXT FROM SIMILAR PAST INCIDENTS:",
- "The following past RCAs were rated helpful by the user. Use this context to guide your investigation:",
- "",
- ]
-
- for i, match in enumerate(matches, 1):
- similarity_pct = int(match["similarity"] * 100)
- context_parts.extend([
- f"### Past Incident {i} (Similarity: {similarity_pct}%)",
- f"- **Alert**: {match.get('alert_title', 'Unknown')}",
- f"- **Service**: {match.get('alert_service', 'Unknown')}",
- f"- **Source**: {match.get('source_type', 'Unknown')}",
- "",
- "**Summary of what was found:**",
- match.get("aurora_summary", "No summary available")[:1000], # Limit length
- "",
- ])
-
- # Add key investigation steps from thoughts (summarized)
- thoughts = match.get("thoughts", [])
- if thoughts:
- # Get the most relevant thoughts (findings and actions)
- key_thoughts = [
- t["content"]
- for t in thoughts
- if t.get("type") in ("finding", "action", "hypothesis", "analysis")
- ][:3]
- if key_thoughts:
- context_parts.append("**Key investigation steps:**")
- for thought in key_thoughts:
- # Truncate long thoughts
- truncated = thought[:200] + "..." if len(thought) > 200 else thought
- context_parts.append(f"- {truncated}")
- context_parts.append("")
-
- # Add commands used during investigation (without outputs)
- citations = match.get("citations", [])
- if citations:
- commands = [
- c.get("command", "")
- for c in citations
- if c.get("command")
- ][:5]
- if commands:
- context_parts.append("**Commands used in investigation:**")
- for cmd in commands:
- truncated = cmd[:150] + "..." if len(cmd) > 150 else cmd
- context_parts.append(f"- `{truncated}`")
- context_parts.append("")
-
- context_parts.extend([
- "---",
- "**Note**: Use the above context as guidance. The current incident may have different root causes.",
- "",
- ])
-
- context = "\n".join(context_parts)
- logger.info(
- f"[AURORA LEARN] Injecting context from {len(matches)} similar good RCAs for user {user_id}"
- )
- logger.info(f"[AURORA LEARN] Context preview:\n{context[:500]}...")
- return context
-
- except Exception as e:
- logger.warning(f"Error getting similar RCA context: {e}")
- return ""
-
-
-def _get_prediscovery_context(user_id: str, alert_title: str, alert_service: str) -> str:
- """Search prediscovery findings relevant to the alert and return formatted context."""
- if not user_id:
- return ""
-
- query = " ".join(filter(None, [alert_title, alert_service]))
- if not query.strip():
- return ""
-
- try:
- from routes.knowledge_base.weaviate_client import _get_weaviate_client
- from weaviate.classes.query import Filter, HybridFusion
- from utils.auth.stateless_auth import get_org_id_for_user
-
- org_id = get_org_id_for_user(user_id)
- if not org_id:
- return ""
-
- _, collection = _get_weaviate_client()
-
- discovery_filter = (
- Filter.by_property("org_id").equal(org_id)
- & Filter.by_property("document_id").like("discovery:*")
- )
-
- response = collection.query.hybrid(
- query=query,
- limit=3,
- alpha=0.5,
- fusion_type=HybridFusion.RANKED,
- filters=discovery_filter,
- return_metadata=["score"],
- )
-
- if not response.objects:
- return ""
-
- parts = [
- "",
- "## INFRASTRUCTURE TOPOLOGY CONTEXT (from pre-discovery):",
- "The following infrastructure mappings were discovered automatically and may be relevant:",
- "",
- ]
-
- for obj in response.objects:
- source = obj.properties.get("source_filename", "")
- content = obj.properties.get("content", "")
- if content:
- label = source.replace("[Auto-Discovery] ", "") if source else "Discovery"
- parts.append(f"### {label}")
- parts.append(content[:2000])
- parts.append("")
-
- parts.append("Use this topology context to understand dependencies and blast radius.")
- parts.append("")
-
- context = "\n".join(parts)
- logger.info(f"[PREDISCOVERY] Injected {len(response.objects)} findings for alert: {query[:50]}")
- return context
-
- except Exception as e:
- logger.warning(f"Error getting prediscovery context: {e}")
- return ""
-
-
def get_user_providers(user_id: str) -> List[str]:
"""Return verified providers for a user.
@@ -401,36 +177,6 @@ def build_rca_prompt(
"",
]
- # Aurora Learn: inject context from similar past incidents
- metadata = payload.get("metadata")
- metadata_service = metadata.get("service", "") if isinstance(metadata, dict) else ""
- alert_service = (
- payload.get("service")
- or payload.get("resource")
- or payload.get("component")
- or metadata_service
- or ""
- )
- if user_id:
- similar_context = _get_similar_good_rcas_context(
- user_id=user_id,
- alert_title=title,
- alert_service=alert_service,
- source_type=source,
- )
- if similar_context:
- prompt_parts.append(similar_context)
-
- # Prediscovery: inject infrastructure topology context
- if user_id:
- prediscovery_context = _get_prediscovery_context(
- user_id=user_id,
- alert_title=title,
- alert_service=alert_service,
- )
- if prediscovery_context:
- prompt_parts.append(prediscovery_context)
-
prompt = "\n".join(prompt_parts)
rail_text = _extract_rail_text_from_payload(payload)
diff --git a/server/chat/background/task.py b/server/chat/background/task.py
index 62d839aeb..f51d60203 100644
--- a/server/chat/background/task.py
+++ b/server/chat/background/task.py
@@ -30,7 +30,7 @@
# ---------------------------------------------------------------------------
-# Per-worker Agent singleton: avoids recreating PostgreSQLClient, WeaviateClient,
+# Per-worker Agent singleton: avoids recreating PostgreSQLClient
# and LLMManager on every background chat task (~2s cold-start savings).
# ---------------------------------------------------------------------------
_worker_agent = None
@@ -46,12 +46,9 @@ def _get_worker_agent():
if _worker_agent is None:
from chat.backend.agent.agent import Agent
from chat.backend.agent.db import PostgreSQLClient
- from chat.backend.agent.weaviate_client import WeaviateClient
pg = PostgreSQLClient()
- wv = WeaviateClient(pg)
_worker_agent = Agent(
- weaviate_client=wv,
postgres_client=pg,
websocket_sender=None,
event_loop=None,
@@ -1328,14 +1325,13 @@ async def _execute_background_chat(
from chat.backend.agent.db import PostgreSQLClient
from chat.backend.agent.utils.state import State
from chat.backend.agent.workflow import Workflow
- from chat.backend.agent.weaviate_client import WeaviateClient
from chat.backend.agent.tools.cloud_tools import set_user_context
from chat.background.background_websocket import BackgroundWebSocket
from main_chatbot import process_workflow_async
try:
- # Reuse a per-worker Agent to avoid recreating PostgreSQLClient, WeaviateClient,
+ # Reuse a per-worker Agent to avoid recreating PostgreSQLClient
# and LLMManager on every task (~2s cold-start savings).
agent = _get_worker_agent()
@@ -1542,6 +1538,16 @@ async def _execute_background_chat(
logger.exception("[BackgroundChat] Failed to enqueue post-RCA summarization")
_update_incident_aurora_status(incident_id, "complete", user_id=user_id)
+ # Save durable learnings from this RCA to org memory
+ try:
+ from services.memory.collector import extract_memories_from_session
+ extract_memories_from_session.delay(
+ session_id=session_id,
+ user_id=user_id,
+ )
+ except Exception:
+ logger.debug("[BackgroundChat] Memory save enqueue failed")
+
# Fallback: rebuild llm_context_history from UI messages if the save was lost.
llm_context = _ensure_llm_context_history(session_id, user_id)
tool_calls = _extract_tool_calls_for_viz(session_id, user_id, llm_context)
diff --git a/server/main_chatbot.py b/server/main_chatbot.py
index 5144f9d3d..d033bce70 100644
--- a/server/main_chatbot.py
+++ b/server/main_chatbot.py
@@ -8,7 +8,6 @@
logging.getLogger("langchain").setLevel(logging.WARNING)
logging.getLogger("websockets").setLevel(logging.WARNING)
logging.getLogger("websockets.server").setLevel(logging.CRITICAL)
-logging.getLogger("weaviate").setLevel(logging.WARNING)
logging.getLogger("redis").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("urllib3").setLevel(logging.WARNING)
@@ -42,7 +41,6 @@
from chat.backend.agent.db import PostgreSQLClient
from chat.backend.agent.utils.state import State
from chat.backend.agent.workflow import Workflow
-from chat.backend.agent.weaviate_client import WeaviateClient
from chat.backend.agent.utils.llm_context_manager import LLMContextManager
from chat.backend.agent.utils.chat_context_manager import ChatContextManager
from utils.db.connection_pool import db_pool
@@ -944,7 +942,6 @@ async def handle_connection(websocket) -> None:
await websocket.close(code=1008, reason="Missing or invalid authentication token")
return
- weaviate_client = None
deployment_listener_task = None
current_user_id = token_user_id
session_id = None # Initialize to avoid UnboundLocalError in exception handler
@@ -953,8 +950,6 @@ async def handle_connection(websocket) -> None:
try:
postgres_client = PostgreSQLClient()
- weaviate_client = WeaviateClient(postgres_client)
- # Note: Agent will be created with websocket_sender in the message processing loop
agent = None
wf = None
except Exception as e:
@@ -967,10 +962,6 @@ async def handle_connection(websocket) -> None:
"session_id": session_id,
}
}))
- # Close the connection to weaviate if opened
- logger.debug(f"weaviate client is {weaviate_client}")
- if weaviate_client:
- weaviate_client.close()
return
# Send ready status to client
@@ -1489,7 +1480,7 @@ async def confirmation_sender(data):
# Create agent and workflow with websocket sender if not already created
if agent is None:
- agent = Agent(weaviate_client=weaviate_client, postgres_client=postgres_client, websocket_sender=websocket_sender, event_loop=asyncio.get_event_loop())
+ agent = Agent(postgres_client=postgres_client, websocket_sender=websocket_sender, event_loop=asyncio.get_event_loop())
logger.info(f"Created agent with websocket sender")
# Hook: check if LLM call is allowed
@@ -1642,9 +1633,6 @@ async def confirmation_sender(data):
await deployment_listener_task
except asyncio.CancelledError:
pass
-
- if weaviate_client:
- weaviate_client.close()
async def main():
"""Start the WebSocket server and health check endpoint."""
diff --git a/server/main_compute.py b/server/main_compute.py
index 9f0f6d63c..28fa034f6 100644
--- a/server/main_compute.py
+++ b/server/main_compute.py
@@ -472,9 +472,9 @@ def enforce_user_org_binding():
import routes.opsgenie.tasks # noqa: F401
app.register_blueprint(opsgenie_bp, url_prefix="/opsgenie")
-# --- Knowledge Base Routes ---
-from routes.knowledge_base import bp as knowledge_base_bp # noqa: F401
-app.register_blueprint(knowledge_base_bp, url_prefix="/api/knowledge-base")
+# --- Memory Routes ---
+from routes.memory import memory_bp # noqa: F401
+app.register_blueprint(memory_bp, url_prefix="/api/memory")
# --- Confluence Integration Routes ---
@@ -515,10 +515,8 @@ def enforce_user_org_binding():
# --- Incidents Routes ---
from routes.incidents_routes import incidents_bp
from routes.incidents_sse import incidents_sse_bp
-from routes.incident_feedback import incident_feedback_bp
app.register_blueprint(incidents_bp)
app.register_blueprint(incidents_sse_bp)
-app.register_blueprint(incident_feedback_bp)
from routes.incidents_findings import findings_bp
app.register_blueprint(findings_bp)
diff --git a/server/requirements.txt b/server/requirements.txt
index d16a8a185..4651b330c 100644
--- a/server/requirements.txt
+++ b/server/requirements.txt
@@ -63,7 +63,6 @@ six==1.17.0
typing_extensions>=4.12.2
urllib3==2.6.3
Werkzeug==3.1.6
-weaviate_client>=4.15.0 # Updated for httpx>=0.28 compatibility
websockets==15.0
pyyaml==6.0.1
celery==5.3.6
diff --git a/server/routes/graph_routes.py b/server/routes/graph_routes.py
index a59c64af5..3edb2c725 100644
--- a/server/routes/graph_routes.py
+++ b/server/routes/graph_routes.py
@@ -147,7 +147,9 @@ def get_infrastructure_context_api(user_id):
with db_pool.get_admin_connection() as conn:
with conn.cursor() as cur:
cur.execute(
- "SELECT content, updated_at FROM infrastructure_context WHERE org_id = %s",
+ """SELECT content, updated_at FROM artifacts
+ WHERE org_id = %s AND category = 'infrastructure'
+ AND title = 'Infrastructure Context'""",
(org_id,),
)
row = cur.fetchone()
diff --git a/server/routes/health_routes.py b/server/routes/health_routes.py
index 1c407f1f1..aee278070 100644
--- a/server/routes/health_routes.py
+++ b/server/routes/health_routes.py
@@ -6,7 +6,6 @@
import logging
import time
import os
-import requests
import socket
from datetime import datetime, timezone
from flask import Blueprint, jsonify
@@ -53,23 +52,6 @@ def check_redis_health():
logger.warning(f"Redis health check failed: {e}", exc_info=True)
return {"status": "unhealthy", "error": "Redis connection failed"}
-def check_weaviate_health():
- """Check Weaviate vector database connectivity."""
- try:
- weaviate_host = os.getenv('WEAVIATE_HOST', 'weaviate')
- weaviate_port = os.getenv('WEAVIATE_PORT', '8080')
- weaviate_secure = os.getenv('WEAVIATE_SECURE', 'false').lower() in ('1', 'true', 'yes')
- scheme = "https" if weaviate_secure else "http"
- response = requests.get(f"{scheme}://{weaviate_host}:{weaviate_port}/v1/.well-known/ready", timeout=5)
- response.raise_for_status()
- return {"status": "healthy", "message": "Weaviate connection successful"}
- except requests.RequestException as e:
- logger.warning(f"Weaviate health check failed: {e}")
- return {"status": "unhealthy", "error": "Weaviate HTTP connection failed"}
- except Exception as e:
- logger.warning(f"Weaviate health check failed: {e}")
- return {"status": "unhealthy", "error": "Weaviate health check failed"}
-
def check_celery_health():
"""Check Celery worker health."""
if not celery_app:
@@ -123,7 +105,6 @@ def health_check():
checks = {
"database": check_database_health(),
"redis": check_redis_health(),
- "weaviate": check_weaviate_health(),
"celery": check_celery_health(),
"chatbot_websocket": check_chatbot_websocket(),
}
diff --git a/server/routes/incident_feedback/__init__.py b/server/routes/incident_feedback/__init__.py
deleted file mode 100644
index 6492391f4..000000000
--- a/server/routes/incident_feedback/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""Incident Feedback module for Aurora Learn feature."""
-
-from routes.incident_feedback.routes import incident_feedback_bp
-
-__all__ = ["incident_feedback_bp"]
diff --git a/server/routes/incident_feedback/routes.py b/server/routes/incident_feedback/routes.py
deleted file mode 100644
index d982616fe..000000000
--- a/server/routes/incident_feedback/routes.py
+++ /dev/null
@@ -1,294 +0,0 @@
-"""API routes for incident feedback (Aurora Learn feature)."""
-
-import logging
-from flask import Blueprint, jsonify, request
-from utils.db.connection_pool import db_pool
-from utils.log_sanitizer import sanitize
-from utils.auth.stateless_auth import (
- get_user_preference,
- store_user_preference,
- get_org_id_from_request,
- set_rls_context,
-)
-from utils.auth.rbac_decorators import require_permission, require_auth_only
-from routes.incident_feedback.weaviate_client import store_good_rca
-from utils.validation import is_valid_uuid
-
-logger = logging.getLogger(__name__)
-
-incident_feedback_bp = Blueprint("incident_feedback", __name__)
-
-# Preference key for Aurora Learn toggle
-AURORA_LEARN_PREFERENCE_KEY = "aurora_learn_enabled"
-
-# Valid feedback types
-VALID_FEEDBACK_TYPES = {"helpful", "not_helpful"}
-
-
-def _is_aurora_learn_enabled(user_id: str) -> bool:
- """Check if Aurora Learn is enabled for a user. Defaults to True."""
- setting = get_user_preference(user_id, AURORA_LEARN_PREFERENCE_KEY, default=True)
- return setting is True
-
-
-# ============================================================================
-# Feedback API
-# ============================================================================
-
-
-@incident_feedback_bp.route("/api/incidents//feedback", methods=["POST"])
-@require_permission("incidents", "write")
-def submit_feedback(user_id, incident_id: str):
-
- if not is_valid_uuid(incident_id):
- return jsonify({"error": "Invalid incident ID format"}), 400
-
- # Check if Aurora Learn is enabled
- if not _is_aurora_learn_enabled(user_id):
- return jsonify({
- "error": "Aurora Learn is disabled. Enable it in Settings to provide feedback.",
- "error_code": "AURORA_LEARN_DISABLED"
- }), 403
-
- org_id = get_org_id_from_request()
-
- data = request.get_json()
- if not data:
- return jsonify({"error": "Missing request body"}), 400
-
- feedback_type = data.get("feedback_type")
- if feedback_type not in VALID_FEEDBACK_TYPES:
- return jsonify({
- "error": f"Invalid feedback_type. Must be one of: {', '.join(VALID_FEEDBACK_TYPES)}"
- }), 400
-
- comment = data.get("comment", "")
- if comment and len(comment) > 2000:
- return jsonify({"error": "Comment too long (max 2000 characters)"}), 400
-
- try:
- with db_pool.get_admin_connection() as conn:
- with conn.cursor() as cursor:
- # Set RLS context
- set_rls_context(cursor, conn, user_id, log_prefix="[IncidentFeedback]")
-
- # Check if feedback already exists (feedback is final)
- cursor.execute(
- """
- SELECT id FROM incident_feedback
- WHERE user_id = %s AND incident_id = %s
- """,
- (user_id, incident_id),
- )
- existing = cursor.fetchone()
- if existing:
- return jsonify({
- "error": "Feedback already submitted for this incident. Feedback cannot be changed."
- }), 409
-
- # Verify the incident exists and belongs to the user
- cursor.execute(
- """
- SELECT id, aurora_status, aurora_summary, alert_title, alert_service,
- source_type, severity
- FROM incidents
- WHERE id = %s AND user_id = %s
- """,
- (incident_id, user_id),
- )
- incident = cursor.fetchone()
- if not incident:
- return jsonify({"error": "Incident not found"}), 404
-
- (
- inc_id,
- aurora_status,
- aurora_summary,
- alert_title,
- alert_service,
- source_type,
- severity,
- ) = incident
-
- # Only allow feedback on completed analyses
- if aurora_status != "complete":
- return jsonify({
- "error": "Can only provide feedback on completed analyses"
- }), 400
-
- # Save feedback to database (don't commit yet for helpful feedback)
- cursor.execute(
- """
- INSERT INTO incident_feedback (user_id, org_id, incident_id, feedback_type, comment)
- VALUES (%s, %s, %s, %s, %s)
- RETURNING id, created_at
- """,
- (user_id, org_id, incident_id, feedback_type, comment or None),
- )
- feedback_row = cursor.fetchone()
- feedback_id = str(feedback_row[0])
- created_at = feedback_row[1]
-
- stored_for_learning = False
-
- # If helpful, store in Weaviate for future reference
- if feedback_type == "helpful":
- # Fetch thoughts and citations
- cursor.execute(
- """
- SELECT content, thought_type, timestamp
- FROM incident_thoughts
- WHERE incident_id = %s
- ORDER BY timestamp ASC
- """,
- (incident_id,),
- )
- thought_rows = cursor.fetchall()
- thoughts = [
- {"content": row[0], "type": row[1], "timestamp": str(row[2])}
- for row in thought_rows
- ]
-
- cursor.execute(
- """
- SELECT citation_key, tool_name, command, output
- FROM incident_citations
- WHERE incident_id = %s
- ORDER BY citation_key
- """,
- (incident_id,),
- )
- citation_rows = cursor.fetchall()
- citations = [
- {
- "key": row[0],
- "tool_name": row[1],
- "command": row[2],
- "output": row[3][:500] if row[3] else "", # Truncate output
- }
- for row in citation_rows
- ]
-
- # Store in Weaviate - if this fails, rollback DB transaction
- stored_for_learning = store_good_rca(
- user_id=user_id,
- incident_id=incident_id,
- feedback_id=feedback_id,
- alert_title=alert_title or "",
- alert_service=alert_service or "",
- source_type=source_type or "",
- severity=severity or "",
- aurora_summary=aurora_summary or "",
- thoughts=thoughts,
- citations=citations,
- org_id=org_id,
- )
-
- if not stored_for_learning:
- # Weaviate storage failed - rollback DB transaction
- conn.rollback()
- logger.error(
- "[FEEDBACK] Weaviate storage failed, rolling back feedback for incident %s",
- sanitize(incident_id),
- )
- return jsonify({
- "error": "Failed to store feedback for learning. Please try again."
- }), 500
-
- # Commit DB transaction after Weaviate succeeds (or for not_helpful)
- conn.commit()
-
- logger.info(
- "[FEEDBACK] User %s submitted %s feedback for incident %s (stored_for_learning=%s)",
- sanitize(user_id),
- sanitize(feedback_type),
- sanitize(incident_id),
- stored_for_learning,
- )
-
- return jsonify({
- "success": True,
- "feedbackId": feedback_id,
- "feedbackType": feedback_type,
- "storedForLearning": stored_for_learning,
- "createdAt": created_at.isoformat() if created_at else None,
- }), 201
-
- except Exception as exc:
- logger.exception("[FEEDBACK] Failed to submit feedback for incident %s", sanitize(incident_id))
- return jsonify({"error": "Failed to submit feedback"}), 500
-
-
-@incident_feedback_bp.route("/api/incidents//feedback", methods=["GET"])
-@require_permission("incidents", "read")
-def get_feedback(user_id, incident_id: str):
-
- if not is_valid_uuid(incident_id):
- return jsonify({"error": "Invalid incident ID format"}), 400
-
- try:
- with db_pool.get_admin_connection() as conn:
- with conn.cursor() as cursor:
- set_rls_context(cursor, conn, user_id, log_prefix="[IncidentFeedback]")
-
- cursor.execute(
- """
- SELECT id, feedback_type, comment, created_at
- FROM incident_feedback
- WHERE user_id = %s AND incident_id = %s
- """,
- (user_id, incident_id),
- )
- row = cursor.fetchone()
-
- if not row:
- return jsonify({"feedback": None}), 200
-
- feedback_id, feedback_type, comment, created_at = row
-
- return jsonify({
- "feedback": {
- "id": str(feedback_id),
- "feedbackType": feedback_type,
- "comment": comment,
- "createdAt": created_at.isoformat() if created_at else None,
- }
- }), 200
-
- except Exception as exc:
- logger.exception("[FEEDBACK] Failed to get feedback for incident %s", sanitize(incident_id))
- return jsonify({"error": "Failed to get feedback"}), 500
-
-
-# ============================================================================
-# Aurora Learn Settings API
-# ============================================================================
-
-
-@incident_feedback_bp.route("/api/user/preferences/aurora-learn", methods=["GET"])
-@require_auth_only
-def get_aurora_learn_setting(user_id):
-
- enabled = _is_aurora_learn_enabled(user_id)
- return jsonify({"enabled": enabled}), 200
-
-
-@incident_feedback_bp.route("/api/user/preferences/aurora-learn", methods=["PUT"])
-@require_auth_only
-def set_aurora_learn_setting(user_id):
-
- data = request.get_json()
- if data is None or "enabled" not in data:
- return jsonify({"error": "Missing 'enabled' field"}), 400
-
- enabled = data["enabled"]
- if not isinstance(enabled, bool):
- return jsonify({"error": "'enabled' must be a boolean"}), 400
-
- try:
- store_user_preference(user_id, AURORA_LEARN_PREFERENCE_KEY, enabled)
- logger.info("[AURORA LEARN] User %s set Aurora Learn to %s", sanitize(user_id), enabled)
- return jsonify({"success": True, "enabled": enabled}), 200
- except Exception as exc:
- logger.exception("[AURORA LEARN] Failed to update setting for user %s", sanitize(user_id))
- return jsonify({"error": "Failed to update setting"}), 500
diff --git a/server/routes/incident_feedback/weaviate_client.py b/server/routes/incident_feedback/weaviate_client.py
deleted file mode 100644
index bc5e06ecd..000000000
--- a/server/routes/incident_feedback/weaviate_client.py
+++ /dev/null
@@ -1,386 +0,0 @@
-"""
-Aurora Learn - Weaviate Client
-
-Manages the IncidentKnowledge collection in Weaviate for the Aurora Learn feature.
-Stores positively-rated RCAs to provide context for similar future incidents.
-"""
-
-import json
-import logging
-import os
-import threading
-from datetime import datetime, timezone
-from typing import Any, Dict, List, Optional
-
-import weaviate
-from weaviate.classes.config import Configure, DataType, Property
-from weaviate.classes.init import AdditionalConfig, Timeout
-from weaviate.classes.query import Filter, MetadataQuery
-from weaviate.util import generate_uuid5
-
-from utils.log_sanitizer import sanitize
-
-logger = logging.getLogger(__name__)
-
-COLLECTION_NAME = "IncidentKnowledge"
-
-
-def _parse_json_field(value: str) -> list:
- """Parse a JSON string field, returning an empty list on failure."""
- try:
- return json.loads(value)
- except (json.JSONDecodeError, TypeError):
- return []
-
-
-# Weaviate connection settings from environment
-WEAVIATE_HOST = os.getenv("WEAVIATE_HOST")
-WEAVIATE_PORT = int(os.getenv("WEAVIATE_PORT"))
-WEAVIATE_GRPC_PORT = int(os.getenv("WEAVIATE_GRPC_PORT"))
-WEAVIATE_SECURE = os.getenv("WEAVIATE_SECURE", "false").lower() in ("1", "true", "yes")
-
-_client: weaviate.WeaviateClient | None = None
-_collection = None
-_client_lock = threading.Lock()
-
-
-def _reset_client() -> None:
- """Reset the client state, attempting to close any existing connection."""
- global _client, _collection
- if _client is not None:
- try:
- _client.close()
- except Exception as e:
- logger.warning(f"[AURORA LEARN] Error closing client: {e}")
- _client = None
- _collection = None
-
-
-def _get_weaviate_client():
- """Get or create the Weaviate client instance."""
- global _client, _collection
-
- # Fast path: check if client is ready without lock
- if _client is not None:
- try:
- if _client.is_ready():
- return _client, _collection
- logger.warning("[AURORA LEARN] Connection not ready, reconnecting...")
- except Exception:
- logger.warning("[AURORA LEARN] Connection lost, reconnecting...")
-
- # Slow path: acquire lock to create/recreate client
- with _client_lock:
- # Double-check after acquiring lock
- if _client is not None:
- try:
- if _client.is_ready():
- return _client, _collection
- except Exception:
- pass
- _reset_client()
-
- try:
- openai_api_key = os.getenv("OPENAI_API_KEY")
- headers = {}
- if openai_api_key:
- headers["X-OpenAI-Api-Key"] = openai_api_key
-
- if WEAVIATE_SECURE:
- _client = weaviate.connect_to_custom(
- http_host=WEAVIATE_HOST,
- http_port=WEAVIATE_PORT,
- http_secure=True,
- grpc_host=WEAVIATE_HOST,
- grpc_port=WEAVIATE_GRPC_PORT,
- grpc_secure=True,
- headers=headers,
- additional_config=AdditionalConfig(
- timeout=Timeout(init=10, query=30, insert=60)
- ),
- )
- else:
- _client = weaviate.connect_to_local(
- host=WEAVIATE_HOST,
- port=WEAVIATE_PORT,
- grpc_port=WEAVIATE_GRPC_PORT,
- headers=headers,
- additional_config=AdditionalConfig(
- timeout=Timeout(init=10, query=30, insert=60)
- ),
- )
-
- logger.info(f"[AURORA LEARN] Connected to {WEAVIATE_HOST}:{WEAVIATE_PORT}")
-
- # Ensure collection exists
- _collection = _ensure_collection(_client)
-
- return _client, _collection
-
- except Exception as e:
- logger.error(f"[AURORA LEARN] Failed to connect: {e}")
- raise
-
-
-def _ensure_collection(client: weaviate.WeaviateClient):
- """Create IncidentKnowledge collection if it doesn't exist."""
- try:
- if client.collections.exists(COLLECTION_NAME):
- logger.info(f"[AURORA LEARN] Collection {COLLECTION_NAME} already exists")
- return client.collections.get(COLLECTION_NAME)
-
- logger.info(f"[AURORA LEARN] Creating collection {COLLECTION_NAME}")
-
- collection = client.collections.create(
- name=COLLECTION_NAME,
- vectorizer_config=Configure.Vectorizer.text2vec_transformers(),
- properties=[
- # Metadata - stored but not vectorized
- Property(name="user_id", data_type=DataType.TEXT, skip_vectorization=True),
- Property(name="org_id", data_type=DataType.TEXT, skip_vectorization=True),
- Property(name="incident_id", data_type=DataType.TEXT, skip_vectorization=True),
- Property(name="feedback_id", data_type=DataType.TEXT, skip_vectorization=True),
- # Core matching signals - vectorized
- Property(name="alert_title", data_type=DataType.TEXT),
- Property(name="alert_service", data_type=DataType.TEXT),
- Property(name="source_type", data_type=DataType.TEXT),
- Property(name="severity", data_type=DataType.TEXT),
- Property(name="aurora_summary", data_type=DataType.TEXT),
- # Retrieved but not vectorized (large JSON / redundant)
- Property(name="thoughts", data_type=DataType.TEXT, skip_vectorization=True),
- Property(name="citations", data_type=DataType.TEXT, skip_vectorization=True),
- Property(name="full_context", data_type=DataType.TEXT, skip_vectorization=True),
- Property(name="created_at", data_type=DataType.DATE),
- ],
- )
-
- logger.info(f"[AURORA LEARN] Collection {COLLECTION_NAME} created successfully")
- return collection
-
- except Exception as e:
- logger.error(f"[AURORA LEARN] Error ensuring collection: {e}")
- raise
-
-
-def store_good_rca(
- user_id: str,
- incident_id: str,
- feedback_id: str,
- alert_title: str,
- alert_service: str,
- source_type: str,
- severity: str,
- aurora_summary: str,
- thoughts: List[Dict[str, Any]],
- citations: List[Dict[str, Any]],
- org_id: str = None,
-) -> bool:
- """
- Store a positively-rated RCA in Weaviate for future reference.
-
- Args:
- user_id: User identifier
- incident_id: Incident identifier
- feedback_id: Feedback record identifier
- alert_title: Title of the alert
- alert_service: Service that triggered the alert
- source_type: Alert source (grafana, datadog, etc.)
- severity: Alert severity
- aurora_summary: Aurora's RCA summary
- thoughts: List of investigation thoughts
- citations: List of evidence citations
-
- Returns:
- True if stored successfully, False otherwise
- """
- try:
- _, collection = _get_weaviate_client()
- now = datetime.now(timezone.utc).isoformat()
-
- # Build full context for embedding - combines all relevant text
- thoughts_text = "\n".join([t.get("content", "") for t in thoughts])
- full_context = f"""
-Alert: {alert_title}
-Service: {alert_service}
-Source: {source_type}
-Severity: {severity}
-
-Summary:
-{aurora_summary}
-
-Investigation:
-{thoughts_text}
-""".strip()
-
- # Generate deterministic UUID based on user_id and incident_id
- uuid = generate_uuid5(f"{user_id}:{incident_id}")
-
- properties = {
- "user_id": user_id,
- "org_id": org_id or "",
- "incident_id": incident_id,
- "feedback_id": feedback_id,
- "alert_title": alert_title,
- "alert_service": alert_service or "unknown",
- "source_type": source_type,
- "severity": severity or "unknown",
- "aurora_summary": aurora_summary,
- "thoughts": json.dumps(thoughts),
- "citations": json.dumps(citations),
- "full_context": full_context,
- "created_at": now,
- }
-
- collection.data.insert(properties=properties, uuid=uuid)
-
- logger.info(
- f"[AURORA LEARN] Stored good RCA for incident {sanitize(incident_id)} (user: {sanitize(user_id)})"
- )
- return True
-
- except Exception as e:
- logger.error(f"[AURORA LEARN] Error storing good RCA: {e}")
- return False
-
-
-def search_similar_good_rcas(
- user_id: str,
- alert_title: str,
- alert_service: str,
- source_type: str,
- limit: int = 2,
- min_score: float = 0.7,
-) -> List[Dict[str, Any]]:
- """
- Search for similar past incidents with positive feedback.
-
- Resolves org_id from user_id and filters by org so all org members
- benefit from shared Aurora Learn knowledge.
-
- Args:
- user_id: User identifier (used to resolve org_id)
- alert_title: Title of the current alert
- alert_service: Service of the current alert
- source_type: Source type of the current alert
- limit: Maximum number of results (default 2)
- min_score: Minimum similarity score (default 0.7)
-
- Returns:
- List of matching RCAs with similarity scores
- """
- try:
- _, collection = _get_weaviate_client()
-
- search_query = f"Alert: {alert_title} Service: {alert_service} Source: {source_type}"
-
- from utils.auth.stateless_auth import get_org_id_for_user
- org_id = get_org_id_for_user(user_id)
-
- if org_id:
- search_filter = Filter.by_property("org_id").equal(org_id)
- else:
- logger.warning("No org_id found for user %s, falling back to user_id filter", user_id)
- search_filter = Filter.by_property("user_id").equal(user_id)
-
- # Perform vector search
- response = collection.query.near_text(
- query=search_query,
- limit=limit,
- filters=search_filter,
- return_metadata=MetadataQuery(distance=True),
- )
-
- results = []
- for obj in response.objects:
- # Convert distance to similarity score (1 - distance for cosine)
- distance = obj.metadata.distance if obj.metadata else 1.0
- similarity = 1.0 - distance
- obj_title = obj.properties.get("alert_title", "?")
- logger.info(f"[AURORA LEARN] Candidate: '{obj_title[:30]}' distance={distance:.3f} similarity={similarity:.3f}")
-
- # Apply minimum score filter
- if similarity < min_score:
- logger.info(f"[AURORA LEARN] Filtered out '{obj_title[:30]}' (similarity {similarity:.3f} < {min_score})")
- continue
-
- thoughts = _parse_json_field(obj.properties.get("thoughts", "[]"))
- citations = _parse_json_field(obj.properties.get("citations", "[]"))
-
- results.append({
- "incident_id": obj.properties.get("incident_id", ""),
- "alert_title": obj.properties.get("alert_title", ""),
- "alert_service": obj.properties.get("alert_service", ""),
- "source_type": obj.properties.get("source_type", ""),
- "severity": obj.properties.get("severity", ""),
- "aurora_summary": obj.properties.get("aurora_summary", ""),
- "thoughts": thoughts,
- "citations": citations,
- "similarity": round(similarity, 3),
- })
-
- logger.info(
- f"[AURORA LEARN] Search for '{alert_title[:30]}...' returned {len(results)} matches (min_score={min_score})"
- )
- return results
-
- except Exception as e:
- logger.error(f"[AURORA LEARN] Error searching for similar RCAs: {e}")
- return []
-
-
-def delete_incident_knowledge(user_id: str, incident_id: str) -> bool:
- """
- Delete stored knowledge for a specific incident.
-
- Args:
- user_id: User identifier
- incident_id: Incident identifier
-
- Returns:
- True if deleted successfully, False otherwise
- """
- try:
- _, collection = _get_weaviate_client()
-
- # Build filter for user_id AND incident_id
- doc_filter = (
- Filter.by_property("user_id").equal(user_id)
- & Filter.by_property("incident_id").equal(incident_id)
- )
-
- result = collection.data.delete_many(where=doc_filter)
-
- deleted_count = result.successful if hasattr(result, "successful") else 0
- logger.info(
- f"[AURORA LEARN] Deleted {deleted_count} knowledge entries for incident {incident_id}"
- )
- return True
-
- except Exception as e:
- logger.error(f"[AURORA LEARN] Error deleting incident knowledge: {e}")
- return False
-
-
-def delete_user_knowledge(user_id: str) -> int:
- """
- Delete all stored knowledge for a user.
-
- Args:
- user_id: User identifier
-
- Returns:
- Number of deleted entries, or -1 if error
- """
- try:
- _, collection = _get_weaviate_client()
-
- user_filter = Filter.by_property("user_id").equal(user_id)
- result = collection.data.delete_many(where=user_filter)
-
- deleted_count = result.successful if hasattr(result, "successful") else 0
- logger.info(f"[AURORA LEARN] Deleted {deleted_count} knowledge entries for user {user_id}")
- return deleted_count
-
- except Exception as e:
- logger.error(f"[AURORA LEARN] Error deleting user knowledge: {e}")
- return -1
diff --git a/server/routes/knowledge_base/__init__.py b/server/routes/knowledge_base/__init__.py
deleted file mode 100644
index 17782fcfd..000000000
--- a/server/routes/knowledge_base/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from .routes import knowledge_base_bp as bp
diff --git a/server/routes/knowledge_base/document_processor.py b/server/routes/knowledge_base/document_processor.py
deleted file mode 100644
index 4cee373c6..000000000
--- a/server/routes/knowledge_base/document_processor.py
+++ /dev/null
@@ -1,337 +0,0 @@
-"""
-Document Processor for Knowledge Base
-
-Handles parsing and chunking of documents for RAG retrieval.
-Supports Markdown, Plain Text, and PDF formats.
-"""
-
-import logging
-import re
-from typing import Any
-
-logger = logging.getLogger(__name__)
-
-TARGET_CHUNK_SIZE = 1500 # ~300-500 tokens
-CHUNK_OVERLAP = 200
-MIN_CHUNK_SIZE = 100
-
-
-class DocumentProcessor:
- """Process documents into chunks for vector storage."""
-
- def __init__(self, user_id: str, document_id: str, original_filename: str):
- self.user_id = user_id
- self.document_id = document_id
- self.original_filename = original_filename
-
- def process(self, content: bytes, file_type: str) -> list[dict[str, Any]]:
- """
- Process document content into chunks.
-
- Args:
- content: Raw file content as bytes
- file_type: One of 'markdown', 'plaintext', 'pdf'
-
- Returns:
- List of chunk dictionaries with 'content', 'heading_context', 'chunk_index'
- """
- try:
- if file_type == "pdf":
- text = self._extract_pdf_text(content)
- else:
- text = self._decode_text(content)
-
- if not text.strip():
- logger.warning(f"[KB Processor] No text extracted from {self.original_filename}")
- return []
-
- if file_type == "markdown":
- chunks = self._chunk_markdown(text)
- else:
- chunks = self._chunk_plaintext(text)
-
- logger.info(
- f"[KB Processor] Processed {self.original_filename}: {len(chunks)} chunks"
- )
- return chunks
-
- except Exception as e:
- logger.exception(f"[KB Processor] Error processing {self.original_filename}: {e}")
- raise
-
- def _decode_text(self, content: bytes) -> str:
- """Decode bytes to text, trying multiple encodings."""
- encodings = ["utf-8", "utf-8-sig", "latin-1", "cp1252"]
- for encoding in encodings:
- try:
- return content.decode(encoding)
- except UnicodeDecodeError:
- continue
- # Fallback: decode with replacement characters
- return content.decode("utf-8", errors="replace")
-
- def _extract_pdf_text(self, content: bytes) -> str:
- """Extract text from PDF using pypdf."""
- try:
- from pypdf import PdfReader
- import io
- except ImportError:
- logger.error("[KB Processor] pypdf not installed")
- raise RuntimeError("PDF processing requires pypdf. Install with: pip install pypdf")
-
- try:
- pdf_reader = PdfReader(io.BytesIO(content))
- text_parts = []
- for page_num, page in enumerate(pdf_reader.pages):
- page_text = page.extract_text()
- if page_text.strip():
- text_parts.append(f"[Page {page_num + 1}]\n{page_text}")
- return "\n\n".join(text_parts)
- except Exception as e:
- logger.error(f"[KB Processor] PDF extraction failed: {e}")
- raise
-
- def _find_code_block_regions(self, text: str) -> list[tuple[int, int]]:
- """Find all fenced code block regions (``` or ~~~) in the text."""
- regions = []
- fence_pattern = re.compile(r'^(`{3,}|~{3,})', re.MULTILINE)
- matches = list(fence_pattern.finditer(text))
-
- # Pair up opening and closing fences
- i = 0
- while i < len(matches) - 1:
- start = matches[i].start()
- fence_char = matches[i].group(1)[0] # ` or ~
- # Find matching closing fence
- for j in range(i + 1, len(matches)):
- if matches[j].group(1)[0] == fence_char:
- end = matches[j].end()
- regions.append((start, end))
- i = j + 1
- break
- else:
- i += 1
-
- return regions
-
- def _is_in_code_block(self, position: int, regions: list[tuple[int, int]]) -> bool:
- """Check if a position falls within any code block region."""
- for start, end in regions:
- if start <= position < end:
- return True
- return False
-
- def _chunk_markdown(self, text: str) -> list[dict[str, Any]]:
- """
- Chunk markdown text with heading-aware splitting.
- Preserves heading hierarchy as context for each chunk.
- """
- chunks = []
- current_headings: list[tuple[int, str]] = []
-
- # Find fenced code block regions to exclude from header detection
- code_block_regions = self._find_code_block_regions(text)
-
- # Split by markdown headers
- header_pattern = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
-
- # Find all headers and their positions (excluding those in code blocks)
- headers = []
- for match in header_pattern.finditer(text):
- # Skip headers inside fenced code blocks
- if self._is_in_code_block(match.start(), code_block_regions):
- continue
- level = len(match.group(1))
- header_text = match.group(2).strip()
- headers.append((match.start(), match.end(), level, header_text))
-
- if not headers:
- # No headers, treat as plain text
- return self._chunk_plaintext(text)
-
- # Process sections between headers
- for i, (_, end, level, header_text) in enumerate(headers):
- # Update heading hierarchy
- current_headings = [h for h in current_headings if h[0] < level]
- current_headings.append((level, header_text))
-
- # Get content until next header or end
- content_start = end
- content_end = headers[i + 1][0] if i + 1 < len(headers) else len(text)
- section_content = text[content_start:content_end].strip()
-
- if not section_content:
- continue
-
- # Build heading context string
- heading_context = " > ".join([h[1] for h in current_headings])
-
- # Chunk the section content
- section_chunks = self._split_text(
- section_content,
- heading_context=heading_context,
- start_index=len(chunks),
- )
- chunks.extend(section_chunks)
-
- return chunks
-
- def _chunk_plaintext(self, text: str) -> list[dict[str, Any]]:
- """Chunk plain text with paragraph-aware splitting."""
- return self._split_text(text, heading_context="", start_index=0)
-
- def _split_text(
- self,
- text: str,
- heading_context: str = "",
- start_index: int = 0,
- ) -> list[dict[str, Any]]:
- """
- Split text into chunks with overlap.
-
- Uses a hierarchy of separators for semantic-aware splitting:
- 1. Double newlines (paragraphs)
- 2. Single newlines
- 3. Sentences
- 4. Character-based fallback
- """
- chunks = []
- text = text.strip()
-
- if not text:
- return []
-
- if len(text) <= TARGET_CHUNK_SIZE:
- return [
- {
- "content": text,
- "heading_context": heading_context,
- "chunk_index": start_index,
- }
- ]
-
- # Try different separators in order of preference
- separators = ["\n\n", "\n", ". ", " "]
-
- current_chunk = ""
- chunk_index = start_index
-
- for separator in separators:
- if separator not in text:
- continue
-
- parts = text.split(separator)
- current_chunk = ""
-
- for i, part in enumerate(parts):
- part = part.strip()
- if not part:
- continue
-
- # Add separator back (except for the last part)
- if i < len(parts) - 1 and separator not in [" "]:
- part = part + separator
-
- potential_chunk = current_chunk + part
-
- if len(potential_chunk) <= TARGET_CHUNK_SIZE:
- current_chunk = potential_chunk
- else:
- # Save current chunk if it's big enough
- if len(current_chunk) >= MIN_CHUNK_SIZE:
- chunks.append({
- "content": current_chunk.strip(),
- "heading_context": heading_context,
- "chunk_index": chunk_index,
- })
- chunk_index += 1
-
- # Handle oversized parts by force-splitting them
- if len(part) > TARGET_CHUNK_SIZE:
- part_chunks = self._force_split(part, heading_context, chunk_index)
- if part_chunks:
- # Add all but the last chunk
- for pc in part_chunks[:-1]:
- chunks.append(pc)
- chunk_index += 1
- # Use the last chunk as current_chunk for overlap continuity
- last_chunk = part_chunks[-1]
- current_chunk = last_chunk.get("content", "")
- else:
- current_chunk = part
- else:
- # Start new chunk with overlap
- overlap_text = self._get_overlap(current_chunk)
- current_chunk = overlap_text + part
- else:
- current_chunk = potential_chunk
-
- # Don't break if we haven't created any chunks yet
- if chunks:
- break
-
- # Add remaining content
- if current_chunk.strip() and len(current_chunk.strip()) >= MIN_CHUNK_SIZE:
- chunks.append({
- "content": current_chunk.strip(),
- "heading_context": heading_context,
- "chunk_index": chunk_index,
- })
-
- # Fallback: if no chunks created, force split by character count
- if not chunks:
- chunks = self._force_split(text, heading_context, start_index)
-
- return chunks
-
- def _get_overlap(self, text: str) -> str:
- """Get the last CHUNK_OVERLAP characters as overlap for next chunk."""
- if len(text) <= CHUNK_OVERLAP:
- return ""
-
- overlap_start = len(text) - CHUNK_OVERLAP
- overlap = text[overlap_start:]
-
- # Only skip to word boundary if we're starting mid-word
- # Check if char before overlap is whitespace (meaning we're at word boundary)
- if overlap_start > 0 and not text[overlap_start - 1].isspace():
- # We're mid-word, skip to first complete word
- space_idx = overlap.find(" ")
- if space_idx > 0:
- overlap = overlap[space_idx + 1:]
-
- return overlap
-
- def _force_split(
- self,
- text: str,
- heading_context: str,
- start_index: int,
- ) -> list[dict[str, Any]]:
- """Force split text by character count when other methods fail."""
- chunks = []
- chunk_index = start_index
-
- i = 0
- while i < len(text):
- end = min(i + TARGET_CHUNK_SIZE, len(text))
-
- # Try to end at a word boundary
- if end < len(text):
- space_idx = text.rfind(" ", i, end)
- if space_idx > i + MIN_CHUNK_SIZE:
- end = space_idx
-
- chunk_text = text[i:end].strip()
- if chunk_text:
- chunks.append({
- "content": chunk_text,
- "heading_context": heading_context,
- "chunk_index": chunk_index,
- })
- chunk_index += 1
-
- # Move forward, accounting for overlap
- i = end - CHUNK_OVERLAP if end < len(text) else end
-
- return chunks
diff --git a/server/routes/knowledge_base/routes.py b/server/routes/knowledge_base/routes.py
deleted file mode 100644
index 9cb1f6001..000000000
--- a/server/routes/knowledge_base/routes.py
+++ /dev/null
@@ -1,576 +0,0 @@
-"""
-Knowledge Base API Routes
-
-Provides endpoints for:
-- Memory: User-defined context always injected into agent prompts
-- Documents: Upload/list/delete documents for RAG retrieval
-"""
-
-import logging
-import os
-import uuid
-from typing import Any
-
-from flask import Blueprint, jsonify, request
-from werkzeug.utils import secure_filename
-
-from utils.db.connection_pool import db_pool
-from utils.log_sanitizer import sanitize
-from utils.auth.rbac_decorators import require_permission
-from utils.auth.stateless_auth import get_org_id_from_request, set_rls_context
-
-logger = logging.getLogger(__name__)
-
-knowledge_base_bp = Blueprint("knowledge_base", __name__)
-_LOG_PREFIX = "[KnowledgeBase]"
-
-MEMORY_MAX_LENGTH = 5000
-ALLOWED_EXTENSIONS = {'md', 'txt', 'pdf'}
-MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
-MAX_DOCUMENTS_PER_USER = 100
-MAX_STORAGE_PER_USER_MB = 1000 # 1GB total
-
-FILE_TYPE_MAP = {'md': 'markdown', 'pdf': 'pdf'}
-
-
-def allowed_file(filename: str) -> bool:
- """Check if file extension is allowed."""
- return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
-
-
-def get_file_type(filename: str) -> str:
- """Get file type from filename extension."""
- ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
- return FILE_TYPE_MAP.get(ext, 'plaintext')
-
-
-def serialize_document(row: tuple) -> dict[str, Any]:
- """Serialize a document row to a dictionary."""
- return {
- "id": str(row[0]),
- "filename": row[1],
- "original_filename": row[2],
- "file_type": row[3],
- "file_size_bytes": row[4],
- "status": row[5],
- "error_message": row[6],
- "chunk_count": row[7],
- "created_at": row[8].isoformat() if row[8] else None,
- "updated_at": row[9].isoformat() if row[9] else None,
- }
-
-
-# =============================================================================
-# Memory Endpoints
-# =============================================================================
-
-@knowledge_base_bp.route("/memory", methods=["GET"])
-@require_permission("knowledge_base", "read")
-def get_memory(user_id):
- """Get the org's knowledge base memory content."""
- org_id = get_org_id_from_request()
-
- try:
- with db_pool.get_user_connection() as conn:
- cursor = conn.cursor()
- set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX)
-
- cursor.execute(
- """
- SELECT content, updated_at
- FROM knowledge_base_memory
- WHERE org_id = %s
- ORDER BY updated_at DESC
- LIMIT 1
- """,
- (org_id,)
- )
- row = cursor.fetchone()
-
- if row:
- return jsonify({
- "content": row[0],
- "updated_at": row[1].isoformat() if row[1] else None
- }), 200
- else:
- return jsonify({
- "content": "",
- "updated_at": None
- }), 200
-
- except Exception as e:
- logger.exception(f"[KB] Error getting memory for user {user_id}: {e}")
- return jsonify({"error": "Failed to retrieve memory"}), 500
-
-
-@knowledge_base_bp.route("/memory", methods=["PUT"])
-@require_permission("knowledge_base", "write")
-def update_memory(user_id):
- """Update user's knowledge base memory content."""
- try:
- data = request.get_json(force=True, silent=True) or {}
- except Exception:
- data = {}
-
- content = data.get("content", "")
-
- # Validate content length
- if len(content) > MEMORY_MAX_LENGTH:
- return jsonify({
- "error": f"Content exceeds maximum length of {MEMORY_MAX_LENGTH} characters"
- }), 400
-
- org_id = get_org_id_from_request()
-
- try:
- with db_pool.get_user_connection() as conn:
- cursor = conn.cursor()
- set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX)
-
- # Upsert the memory content
- cursor.execute(
- """
- INSERT INTO knowledge_base_memory (user_id, org_id, content, updated_at)
- VALUES (%s, %s, %s, CURRENT_TIMESTAMP)
- ON CONFLICT (user_id, org_id)
- DO UPDATE SET content = EXCLUDED.content, updated_at = CURRENT_TIMESTAMP
- RETURNING updated_at
- """,
- (user_id, org_id, content)
- )
- row = cursor.fetchone()
- conn.commit()
-
- logger.info(f"[KB] Updated memory for user {user_id} ({len(content)} chars)")
-
- return jsonify({
- "success": True,
- "content": content,
- "updated_at": row[0].isoformat() if row and row[0] else None
- }), 200
-
- except Exception as e:
- logger.exception(f"[KB] Error updating memory for user {user_id}: {e}")
- return jsonify({"error": "Failed to update memory"}), 500
-
-
-# =============================================================================
-# Document Endpoints
-# =============================================================================
-
-@knowledge_base_bp.route("/documents", methods=["GET"])
-@require_permission("knowledge_base", "read")
-def list_documents(user_id):
- """List all documents for the user."""
- org_id = get_org_id_from_request()
-
- try:
- with db_pool.get_user_connection() as conn:
- cursor = conn.cursor()
- set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX)
-
- cursor.execute(
- """
- SELECT id, filename, original_filename, file_type, file_size_bytes,
- status, error_message, chunk_count, created_at, updated_at
- FROM knowledge_base_documents
- WHERE org_id = %s
- ORDER BY created_at DESC
- """,
- (org_id,)
- )
- documents = [serialize_document(row) for row in cursor.fetchall()]
-
- # Calculate usage stats
- total_bytes = sum(d.get("file_size_bytes", 0) for d in documents)
- usage = {
- "document_count": len(documents),
- "document_limit": MAX_DOCUMENTS_PER_USER,
- "storage_used_mb": round(total_bytes / (1024 * 1024), 2),
- "storage_limit_mb": MAX_STORAGE_PER_USER_MB,
- }
-
- return jsonify({"documents": documents, "usage": usage}), 200
-
- except Exception as e:
- logger.exception(f"[KB] Error listing documents for user {user_id}: {e}")
- return jsonify({"error": "Failed to list documents"}), 500
-
-
-@knowledge_base_bp.route("/upload", methods=["POST"])
-@require_permission("knowledge_base", "write")
-def upload_document(user_id):
- """Upload a new document for processing."""
- org_id = get_org_id_from_request()
-
- # Check if file is in request
- if 'file' not in request.files:
- return jsonify({"error": "No file provided"}), 400
-
- file = request.files['file']
-
- if file.filename == '':
- return jsonify({"error": "No file selected"}), 400
-
- if not allowed_file(file.filename):
- return jsonify({
- "error": f"File type not allowed. Supported types: {', '.join(ALLOWED_EXTENSIONS)}"
- }), 400
-
- # Check file size
- file.seek(0, os.SEEK_END)
- file_size = file.tell()
- file.seek(0)
-
- if file_size > MAX_FILE_SIZE:
- return jsonify({
- "error": f"File too large. Maximum size is {MAX_FILE_SIZE // (1024*1024)}MB"
- }), 400
-
- # Check user limits (document count and total storage)
- limit_error = _check_user_limits(user_id, file_size)
- if limit_error:
- return jsonify({"error": limit_error}), 400
-
- original_filename = secure_filename(file.filename)
- doc_id = str(uuid.uuid4())
-
- # Validate secure_filename result - it may be empty or lose extension
- if not original_filename or '.' not in original_filename:
- # Try to extract extension from raw filename and reconstruct
- raw_ext = ''
- if file.filename and '.' in file.filename:
- raw_ext = file.filename.rsplit('.', 1)[1].lower()
- if raw_ext in ALLOWED_EXTENSIONS:
- original_filename = f"{doc_id}.{raw_ext}"
- else:
- return jsonify({
- "error": "Invalid filename. Please use a file with a valid extension (.md, .txt, .pdf)."
- }), 400
-
- file_type = get_file_type(original_filename)
-
- # Generate a unique filename to avoid collisions
- filename = f"{doc_id}_{original_filename}"
-
- try:
- with db_pool.get_user_connection() as conn:
- cursor = conn.cursor()
- set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX)
-
- # Check if document with same original filename exists
- cursor.execute(
- """
- SELECT id FROM knowledge_base_documents
- WHERE org_id = %s AND original_filename = %s
- """,
- (org_id, original_filename)
- )
- existing = cursor.fetchone()
- if existing:
- return jsonify({
- "error": f"Document '{original_filename}' already exists. Delete it first to re-upload."
- }), 409
-
- # Create document record with 'uploading' status
- cursor.execute(
- """
- INSERT INTO knowledge_base_documents
- (id, user_id, org_id, filename, original_filename, file_type, file_size_bytes, status)
- VALUES (%s, %s, %s, %s, %s, %s, %s, 'uploading')
- RETURNING id, created_at
- """,
- (doc_id, user_id, org_id, filename, original_filename, file_type, file_size)
- )
- row = cursor.fetchone()
- conn.commit()
-
- logger.info(f"[KB] Created document record {doc_id} for user {user_id}: {original_filename}")
-
- # Store file to local filesystem and trigger processing task
- storage_path = None
- try:
- storage_path = _upload_file(user_id, doc_id, filename, file)
-
- # Update document with storage path and set status to processing
- try:
- with db_pool.get_user_connection() as conn:
- cursor = conn.cursor()
- set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX)
-
- cursor.execute(
- """
- UPDATE knowledge_base_documents
- SET storage_path = %s, status = 'processing', updated_at = CURRENT_TIMESTAMP
- WHERE id = %s AND org_id = %s
- """,
- (storage_path, doc_id, org_id)
- )
- conn.commit()
- except Exception as db_error:
- # DB update failed after file upload - clean up orphan file
- logger.exception(f"[KB] DB update failed after file upload: {db_error}")
- try:
- _delete_file(storage_path)
- logger.info(f"[KB] Cleaned up orphan file: {storage_path}")
- except Exception as cleanup_error:
- logger.warning(f"[KB] Failed to clean up file {storage_path}: {cleanup_error}")
- raise # Re-raise to hit outer except
-
- # Trigger Celery task for document processing
- from routes.knowledge_base.tasks import process_document
- process_document.delay(doc_id, user_id, storage_path)
-
- logger.info(f"[KB] Uploaded document {doc_id} to {storage_path}, triggered processing")
-
- except Exception as upload_error:
- logger.exception(f"[KB] Failed to upload document {doc_id}: {upload_error}")
- # Update status to failed - use generic message for users, log full error server-side
- with db_pool.get_user_connection() as conn:
- cursor = conn.cursor()
- set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX)
-
- cursor.execute(
- """
- UPDATE knowledge_base_documents
- SET status = 'failed', error_message = %s, updated_at = CURRENT_TIMESTAMP
- WHERE id = %s AND org_id = %s
- """,
- ("Document upload failed. Please try again.", doc_id, org_id)
- )
- conn.commit()
-
- return jsonify({"error": "Failed to upload document"}), 500
-
- return jsonify({
- "id": doc_id,
- "filename": filename,
- "original_filename": original_filename,
- "file_type": file_type,
- "file_size_bytes": file_size,
- "status": "processing",
- "created_at": row[1].isoformat() if row and row[1] else None
- }), 201
-
- except Exception as e:
- logger.exception(f"[KB] Error uploading document for user {user_id}: {e}")
- return jsonify({"error": "Failed to upload document"}), 500
-
-
-@knowledge_base_bp.route("/documents/", methods=["GET"])
-@require_permission("knowledge_base", "read")
-def get_document(user_id, doc_id: str):
- """Get a specific document's details."""
- org_id = get_org_id_from_request()
-
- try:
- with db_pool.get_user_connection() as conn:
- cursor = conn.cursor()
- set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX)
-
- cursor.execute(
- """
- SELECT id, filename, original_filename, file_type, file_size_bytes,
- status, error_message, chunk_count, created_at, updated_at
- FROM knowledge_base_documents
- WHERE id = %s AND org_id = %s
- """,
- (doc_id, org_id)
- )
- row = cursor.fetchone()
-
- if not row:
- return jsonify({"error": "Document not found"}), 404
-
- return jsonify(serialize_document(row)), 200
-
- except Exception as e:
- logger.exception(f"[KB] Error getting document {sanitize(doc_id)} for user {sanitize(user_id)}: {e}")
- return jsonify({"error": "Failed to get document"}), 500
-
-
-@knowledge_base_bp.route("/documents/", methods=["DELETE"])
-@require_permission("knowledge_base", "write")
-def delete_document(user_id, doc_id: str):
- """Delete a document and its chunks."""
- org_id = get_org_id_from_request()
-
- try:
- with db_pool.get_user_connection() as conn:
- cursor = conn.cursor()
- set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX)
-
- # Get document info first
- cursor.execute(
- """
- SELECT storage_path, original_filename
- FROM knowledge_base_documents
- WHERE id = %s AND org_id = %s
- """,
- (doc_id, org_id)
- )
- row = cursor.fetchone()
-
- if not row:
- return jsonify({"error": "Document not found"}), 404
-
- storage_path = row[0]
- original_filename = row[1]
-
- # Delete from Weaviate first
- try:
- from routes.knowledge_base.weaviate_client import delete_document_chunks
- delete_document_chunks(user_id, doc_id)
- logger.info(f"[KB] Deleted Weaviate chunks for document {sanitize(doc_id)}")
- except Exception as weaviate_error:
- logger.warning(f"[KB] Failed to delete Weaviate chunks for {sanitize(doc_id)}: {weaviate_error}")
-
- # Delete from local filesystem
- if storage_path:
- try:
- _delete_file(storage_path)
- logger.info(f"[KB] Deleted file {storage_path}")
- except Exception as file_error:
- logger.warning(f"[KB] Failed to delete file {storage_path}: {file_error}")
-
- # Delete from database
- cursor.execute(
- """
- DELETE FROM knowledge_base_documents
- WHERE id = %s AND org_id = %s
- """,
- (doc_id, org_id)
- )
- conn.commit()
-
- logger.info(f"[KB] Deleted document {sanitize(doc_id)} ({sanitize(original_filename)}) for user {sanitize(user_id)}")
-
- return jsonify({"success": True}), 200
-
- except Exception as e:
- logger.exception(f"[KB] Error deleting document {sanitize(doc_id)} for user {sanitize(user_id)}: {e}")
- return jsonify({"error": "Failed to delete document"}), 500
-
-
-@knowledge_base_bp.route("/search", methods=["POST"])
-@require_permission("knowledge_base", "read")
-def search_documents(user_id):
- """Search the knowledge base (for direct API usage, not agent tool)."""
- try:
- data = request.get_json(force=True, silent=True) or {}
- except Exception:
- data = {}
-
- query = data.get("query", "")
- limit = data.get("limit", 5)
-
- if not query:
- return jsonify({"error": "Query is required"}), 400
-
- if not isinstance(limit, int) or limit < 1 or limit > 20:
- limit = 5
-
- try:
- from routes.knowledge_base.weaviate_client import search_knowledge_base
- org_id = get_org_id_from_request()
- results = search_knowledge_base(user_id, query, limit, org_id=org_id)
-
- return jsonify({
- "query": query,
- "results": results
- }), 200
-
- except Exception as e:
- logger.exception(f"[KB] Error searching for user {user_id}: {e}")
- return jsonify({"error": "Failed to search knowledge base"}), 500
-
-
-# =============================================================================
-# Helper Functions
-# =============================================================================
-
-def _upload_file(user_id: str, doc_id: str, filename: str, file) -> str:
- """Upload file to storage and return the storage URI."""
- from utils.storage.storage import get_storage_manager
-
- storage = get_storage_manager(user_id=user_id)
- storage_path = f"knowledge-base/{doc_id}/{filename}"
-
- # Upload file to storage (SeaWeedFS via S3-compatible API)
- storage_uri = storage.upload_file(file, storage_path, user_id=user_id)
-
- return storage_uri
-
-
-def _delete_file(storage_path: str) -> None:
- """Delete file from storage."""
- from utils.storage.storage import get_storage_manager
-
- # Extract path and user_id from URI or path
- user_id = None
- path = storage_path
-
- # If it's an s3:// URI, extract the path
- if storage_path.startswith("s3://"):
- # Extract path: s3://bucket/users/{user_id}/knowledge-base/... -> users/{user_id}/knowledge-base/...
- parts = storage_path[5:].split("/", 1)
- if len(parts) > 1:
- path = parts[1]
-
- # Extract user_id from path (format: users/{user_id}/knowledge-base/...)
- if path.startswith("users/"):
- path_parts = path.split("/")
- if len(path_parts) > 1:
- user_id = path_parts[1]
- # Remove users/{user_id}/ prefix to get relative path for delete
- relative_path = "/".join(path_parts[2:]) if len(path_parts) > 2 else ""
- else:
- relative_path = path
- else:
- # Path doesn't have users/ prefix, use as-is
- relative_path = path
-
- storage = get_storage_manager(user_id=user_id)
- storage.delete_file(relative_path, user_id=user_id)
-
-
-def _check_user_limits(user_id: str, file_size: int) -> str | None:
- """
- Check if org has exceeded document or storage limits.
-
- Returns error message if limit exceeded, None if OK.
- """
- org_id = get_org_id_from_request()
- try:
- with db_pool.get_user_connection() as conn:
- cursor = conn.cursor()
- set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX)
-
- cursor.execute(
- """
- SELECT COUNT(*), COALESCE(SUM(file_size_bytes), 0)
- FROM knowledge_base_documents
- WHERE org_id = %s
- """,
- (org_id,)
- )
- doc_count, total_bytes = cursor.fetchone()
-
- # Check document count limit
- if doc_count >= MAX_DOCUMENTS_PER_USER:
- return f"Document limit reached ({MAX_DOCUMENTS_PER_USER} documents). Delete some documents to upload more."
-
- # Check storage limit (convert to MB for comparison)
- # Convert total_bytes to float to avoid Decimal + float type mismatch
- total_mb = float(total_bytes) / (1024 * 1024)
- new_file_mb = file_size / (1024 * 1024)
-
- if total_mb + new_file_mb > MAX_STORAGE_PER_USER_MB:
- return f"Storage limit reached ({MAX_STORAGE_PER_USER_MB}MB). Delete some documents to free up space."
-
- return None
-
- except Exception as e:
- logger.error(f"[KB] Error checking user limits: {e}")
- # Don't block upload on limit check failure
- return None
diff --git a/server/routes/knowledge_base/tasks.py b/server/routes/knowledge_base/tasks.py
deleted file mode 100644
index e301959fe..000000000
--- a/server/routes/knowledge_base/tasks.py
+++ /dev/null
@@ -1,261 +0,0 @@
-"""
-Celery Tasks for Knowledge Base
-
-Background tasks for document processing:
-- Download from local filesystem
-- Parse document
-- Chunk content
-- Store in Weaviate
-"""
-
-import logging
-
-from celery_config import celery_app
-from utils.db.connection_pool import db_pool
-from utils.auth.stateless_auth import get_org_id_for_user
-
-logger = logging.getLogger(__name__)
-
-
-@celery_app.task(
- bind=True,
- max_retries=3,
- default_retry_delay=30,
- name="knowledge_base.process_document",
-)
-def process_document(
- self,
- document_id: str,
- user_id: str,
- storage_path: str,
-) -> None:
- """
- Process a document: download, parse, chunk, and store embeddings.
-
- Args:
- document_id: UUID of the document in knowledge_base_documents
- user_id: User identifier
- storage_path: Path to the file in local filesystem
- """
- logger.info(f"[KB Task] Processing document {document_id} for user {user_id}")
-
- try:
- # 1. Update status to 'processing'
- _update_document_status(document_id, user_id, "processing")
-
- # 2. Get document metadata
- doc_info = _get_document_info(document_id, user_id)
- if not doc_info:
- raise ValueError(f"Document {document_id} not found")
-
- original_filename = doc_info["original_filename"]
- file_type = doc_info["file_type"]
-
- logger.info(f"[KB Task] Reading {original_filename} from {storage_path}")
-
- # 3. Read file from local filesystem
- content = _download_file(storage_path)
- if not content:
- raise ValueError("Failed to read file from filesystem")
-
- logger.info(f"[KB Task] Downloaded {len(content)} bytes, parsing as {file_type}")
-
- # 4. Parse and chunk document
- from routes.knowledge_base.document_processor import DocumentProcessor
-
- processor = DocumentProcessor(user_id, document_id, original_filename)
- chunks = processor.process(content, file_type)
-
- if not chunks:
- raise ValueError("No content could be extracted from document")
-
- logger.info(f"[KB Task] Generated {len(chunks)} chunks")
-
- # 5. Store chunks in Weaviate
- from routes.knowledge_base.weaviate_client import insert_chunks
-
- inserted_count = insert_chunks(
- user_id=user_id,
- document_id=document_id,
- source_filename=original_filename,
- chunks=chunks,
- org_id=get_org_id_for_user(user_id),
- )
-
- logger.info(f"[KB Task] Stored {inserted_count} chunks in Weaviate")
-
- # 6. Update status to 'ready' with chunk count
- _update_document_status(
- document_id,
- user_id,
- "ready",
- chunk_count=inserted_count,
- )
-
- logger.info(f"[KB Task] Document {document_id} processed successfully")
-
- except Exception as exc:
- logger.exception(f"[KB Task] Error processing document {document_id}: {exc}")
-
- # Only set failed status on final retry attempt
- if self.request.retries >= self.max_retries:
- # Final attempt - mark as failed with generic user message
- _update_document_status(
- document_id,
- user_id,
- "failed",
- error_message="Document processing failed. Please try uploading again.",
- )
- else:
- # Retry with exponential backoff
- raise self.retry(exc=exc)
-
-
-def _update_document_status(
- document_id: str,
- user_id: str,
- status: str,
- chunk_count: int | None = None,
- error_message: str | None = None,
-) -> None:
- """Update document status in database."""
- try:
- with db_pool.get_admin_connection() as conn:
- cursor = conn.cursor()
-
- # No RLS needed — knowledge_base_documents not RLS-protected
- cursor.execute(
- """
- UPDATE knowledge_base_documents
- SET status = %s,
- chunk_count = COALESCE(%s, chunk_count),
- error_message = COALESCE(%s, error_message),
- updated_at = CURRENT_TIMESTAMP
- WHERE id = %s AND user_id = %s
- """,
- (status, chunk_count, error_message, document_id, user_id),
- )
- conn.commit()
- logger.info(f"[KB Task] Updated document {document_id} status to {status}")
-
- except Exception as e:
- logger.error(f"[KB Task] Error updating document status: {e}")
- raise # Propagate to allow retry or alerting
-
-
-def _get_document_info(document_id: str, user_id: str) -> dict | None:
- """Get document metadata from database.
-
- Returns:
- Document info dict if found, None if not found.
-
- Raises:
- Exception: Re-raises database errors to distinguish from "not found".
- """
- try:
- with db_pool.get_admin_connection() as conn:
- cursor = conn.cursor()
- # No RLS needed — knowledge_base_documents not RLS-protected
- cursor.execute(
- """
- SELECT original_filename, file_type, storage_path
- FROM knowledge_base_documents
- WHERE id = %s AND user_id = %s
- """,
- (document_id, user_id),
- )
- row = cursor.fetchone()
-
- if row:
- return {
- "original_filename": row[0],
- "file_type": row[1],
- "storage_path": row[2],
- }
- return None
-
- except Exception as e:
- logger.error(f"[KB Task] Error getting document info: {e}")
- raise # Re-raise to distinguish DB errors from "not found"
-
-
-def _download_file(storage_path: str) -> bytes | None:
- """Download file content from storage."""
- try:
- from utils.storage.storage import get_storage_manager
-
- # Extract path and user_id from URI or path
- user_id = None
- path = storage_path
-
- # If it's an s3:// URI, extract the path
- if storage_path.startswith("s3://"):
- # Extract path: s3://bucket/users/{user_id}/knowledge-base/... -> users/{user_id}/knowledge-base/...
- parts = storage_path[5:].split("/", 1)
- if len(parts) > 1:
- path = parts[1]
-
- # Extract user_id from path (format: users/{user_id}/knowledge-base/...)
- if path.startswith("users/"):
- path_parts = path.split("/")
- if len(path_parts) > 1:
- user_id = path_parts[1]
- # Remove users/{user_id}/ prefix to get relative path for download
- relative_path = "/".join(path_parts[2:]) if len(path_parts) > 2 else ""
- else:
- relative_path = path
- else:
- # Path doesn't have users/ prefix, use as-is
- relative_path = path
-
- storage = get_storage_manager(user_id=user_id)
- content = storage.download_bytes(relative_path, user_id=user_id)
- return content
-
- except Exception as e:
- logger.error(f"[KB Task] Error downloading file: {e}")
- return None
-
-
-@celery_app.task(name="knowledge_base.cleanup_stale_documents")
-def cleanup_stale_documents() -> dict:
- """
- Mark documents stuck in 'processing' or 'uploading' for >3 minutes as failed.
-
- This handles edge cases where:
- - Celery worker crashed mid-processing
- - DB update failed after max retries
- - Network issues caused silent failures
- """
- try:
- with db_pool.get_admin_connection() as conn:
- cursor = conn.cursor()
-
- # No RLS needed — knowledge_base_documents not RLS-protected
- cursor.execute(
- """
- UPDATE knowledge_base_documents
- SET status = 'failed',
- error_message = 'Processing timed out. Please try uploading again.',
- updated_at = CURRENT_TIMESTAMP
- WHERE status IN ('processing', 'uploading')
- AND updated_at < CURRENT_TIMESTAMP - INTERVAL '3 minutes'
- RETURNING id, user_id, original_filename
- """
- )
- stale_docs = cursor.fetchall()
- conn.commit()
-
- if stale_docs:
- for doc_id, user_id, filename in stale_docs:
- logger.warning(
- f"[KB Cleanup] Marked stale document as failed: {filename} "
- f"(id={doc_id}, user={user_id})"
- )
-
- logger.info(f"[KB Cleanup] Cleaned up {len(stale_docs)} stale documents")
- return {"cleaned": len(stale_docs)}
-
- except Exception as e:
- logger.error(f"[KB Cleanup] Error cleaning up stale documents: {e}")
- return {"error": str(e)}
diff --git a/server/routes/knowledge_base/weaviate_client.py b/server/routes/knowledge_base/weaviate_client.py
deleted file mode 100644
index 419892101..000000000
--- a/server/routes/knowledge_base/weaviate_client.py
+++ /dev/null
@@ -1,394 +0,0 @@
-"""
-Knowledge Base Weaviate Client
-
-Manages the KnowledgeBaseChunk collection in Weaviate for storing
-and retrieving document chunks with vector embeddings.
-"""
-
-import logging
-import os
-from datetime import datetime, timezone
-from typing import Any
-
-import weaviate
-from weaviate.classes.config import Configure, DataType, Property
-from weaviate.classes.init import AdditionalConfig, Timeout
-from weaviate.classes.query import Filter, HybridFusion
-from weaviate.util import generate_uuid5
-
-from utils.log_sanitizer import sanitize
-
-logger = logging.getLogger(__name__)
-
-COLLECTION_NAME = "KnowledgeBaseChunk"
-
-# Weaviate connection settings from environment
-WEAVIATE_HOST = os.getenv("WEAVIATE_HOST")
-WEAVIATE_PORT = int(os.getenv("WEAVIATE_PORT"))
-WEAVIATE_GRPC_PORT = int(os.getenv("WEAVIATE_GRPC_PORT"))
-WEAVIATE_SECURE = os.getenv("WEAVIATE_SECURE", "false").lower() in ("1", "true", "yes")
-
-_client: weaviate.WeaviateClient | None = None
-_collection = None
-
-
-def _get_weaviate_client():
- """Get or create the Weaviate client instance."""
- global _client, _collection
-
- if _client is not None:
- try:
- # Check if connection is still valid
- ready = _client.is_ready()
- if not ready:
- logger.warning("[KB Weaviate] Connection not ready (is_ready returned False), reconnecting...")
- try:
- _client.close()
- except Exception:
- pass
- _client = None
- _collection = None
- else:
- return _client, _collection
- except Exception:
- logger.warning("[KB Weaviate] Connection lost, reconnecting...")
- try:
- _client.close()
- except Exception:
- pass # Best effort cleanup
- _client = None
- _collection = None
-
- try:
- openai_api_key = os.getenv("OPENAI_API_KEY")
- headers = {}
- if openai_api_key:
- headers["X-OpenAI-Api-Key"] = openai_api_key
-
- if WEAVIATE_SECURE:
- _client = weaviate.connect_to_custom(
- http_host=WEAVIATE_HOST,
- http_port=WEAVIATE_PORT,
- http_secure=True,
- grpc_host=WEAVIATE_HOST,
- grpc_port=WEAVIATE_GRPC_PORT,
- grpc_secure=True,
- headers=headers,
- additional_config=AdditionalConfig(
- timeout=Timeout(init=10, query=30, insert=60)
- ),
- )
- else:
- _client = weaviate.connect_to_local(
- host=WEAVIATE_HOST,
- port=WEAVIATE_PORT,
- grpc_port=WEAVIATE_GRPC_PORT,
- headers=headers,
- additional_config=AdditionalConfig(
- timeout=Timeout(init=10, query=30, insert=60)
- ),
- )
-
- logger.info(f"[KB Weaviate] Connected to {WEAVIATE_HOST}:{WEAVIATE_PORT}")
-
- # Ensure collection exists
- _collection = _ensure_collection(_client)
-
- return _client, _collection
-
- except Exception as e:
- logger.error(f"[KB Weaviate] Failed to connect: {e}")
- raise
-
-
-def _ensure_collection(client: weaviate.WeaviateClient):
- """Create KnowledgeBaseChunk collection if it doesn't exist."""
- try:
- if client.collections.exists(COLLECTION_NAME):
- logger.info(f"[KB Weaviate] Collection {COLLECTION_NAME} already exists")
- return client.collections.get(COLLECTION_NAME)
-
- logger.info(f"[KB Weaviate] Creating collection {COLLECTION_NAME}")
-
- collection = client.collections.create(
- name=COLLECTION_NAME,
- vectorizer_config=Configure.Vectorizer.text2vec_transformers(),
- properties=[
- Property(name="user_id", data_type=DataType.TEXT),
- Property(name="org_id", data_type=DataType.TEXT),
- Property(name="document_id", data_type=DataType.TEXT),
- Property(name="chunk_index", data_type=DataType.INT),
- Property(name="content", data_type=DataType.TEXT),
- Property(name="heading_context", data_type=DataType.TEXT),
- Property(name="source_filename", data_type=DataType.TEXT),
- Property(name="created_at", data_type=DataType.DATE),
- ],
- )
-
- logger.info(f"[KB Weaviate] Collection {COLLECTION_NAME} created successfully")
- return collection
-
- except Exception as e:
- logger.error(f"[KB Weaviate] Error ensuring collection: {e}")
- raise
-
-
-def insert_chunks(
- user_id: str,
- document_id: str,
- source_filename: str,
- chunks: list[dict[str, Any]],
- org_id: str = None,
-) -> int:
- """
- Insert document chunks into Weaviate.
-
- Args:
- user_id: User identifier
- document_id: Document identifier (UUID)
- source_filename: Original filename of the document
- chunks: List of chunk dictionaries with:
- - content: str (the chunk text)
- - heading_context: str (optional, parent heading hierarchy)
- - chunk_index: int (position in document)
- org_id: Organization identifier for tenant isolation
-
- Returns:
- Number of successfully inserted chunks
- """
- if not chunks:
- return 0
-
- try:
- _, collection = _get_weaviate_client()
- success_count = 0
- now = datetime.now(timezone.utc).isoformat()
-
- with collection.batch.dynamic() as batch:
- for chunk in chunks:
- try:
- chunk_index = chunk.get("chunk_index", 0)
- # Generate deterministic UUID
- uuid = generate_uuid5(f"{user_id}:{document_id}:{chunk_index}")
-
- properties = {
- "user_id": user_id,
- "document_id": document_id,
- "chunk_index": chunk_index,
- "content": chunk.get("content", ""),
- "heading_context": chunk.get("heading_context", ""),
- "source_filename": source_filename,
- "created_at": now,
- }
- if org_id:
- properties["org_id"] = org_id
-
- batch.add_object(properties=properties, uuid=uuid)
- success_count += 1
-
- except Exception as e:
- logger.error(f"[KB Weaviate] Error adding chunk {chunk_index}: {e}")
-
- # Check for failed objects
- if collection.batch.failed_objects:
- logger.error(
- f"[KB Weaviate] Batch insertion had {len(collection.batch.failed_objects)} failures"
- )
- for i, failed in enumerate(collection.batch.failed_objects[:5]):
- logger.error(f"[KB Weaviate] Failed object {i+1}: {failed}")
- actual_success = success_count - len(collection.batch.failed_objects)
- logger.info(
- f"[KB Weaviate] Inserted {actual_success}/{len(chunks)} chunks for doc {document_id}"
- )
- return actual_success
-
- logger.info(
- f"[KB Weaviate] Successfully inserted {success_count} chunks for doc {document_id}"
- )
- return success_count
-
- except Exception as e:
- logger.error(f"[KB Weaviate] Error inserting chunks: {e}")
- raise
-
-
-def search_knowledge_base(
- user_id: str,
- query: str,
- limit: int = 5,
- alpha: float = 0.5,
- min_score: float = 0.0,
- org_id: str = None,
-) -> list[dict[str, Any]]:
- """
- Search the knowledge base using hybrid search.
-
- Args:
- user_id: User identifier
- query: Search query string
- limit: Maximum number of results
- alpha: Balance between vector (1.0) and keyword (0.0) search
- min_score: Minimum score threshold (0.0 to skip filtering)
- org_id: Organization identifier for tenant isolation
-
- Returns:
- List of result dictionaries with content, source, and score
- """
- if not query.strip():
- return []
-
- try:
- _, collection = _get_weaviate_client()
-
- # Build filter: user_id OR org_id (if available) for org-shared access
- user_filter = Filter.by_property("user_id").equal(user_id)
- if org_id:
- org_filter = Filter.by_property("org_id").equal(org_id)
- combined_filter = user_filter | org_filter
- else:
- combined_filter = user_filter
-
- # Perform hybrid search (scoped to user + org)
- response = collection.query.hybrid(
- query=query,
- limit=limit,
- alpha=alpha,
- fusion_type=HybridFusion.RANKED,
- filters=combined_filter,
- return_metadata=["score"],
- )
-
- results = []
- for obj in response.objects:
- score = obj.metadata.score if obj.metadata else 0.0
-
- # Apply minimum score filter
- if min_score > 0.0 and score < min_score:
- continue
-
- results.append({
- "content": obj.properties.get("content", ""),
- "heading_context": obj.properties.get("heading_context", ""),
- "source_filename": obj.properties.get("source_filename", ""),
- "document_id": obj.properties.get("document_id", ""),
- "chunk_index": obj.properties.get("chunk_index", 0),
- "score": score,
- })
-
- logger.info(
- f"[KB Weaviate] Search for '{sanitize(query)[:50]}...' returned {len(results)} results"
- )
- return results
-
- except Exception as e:
- logger.error(f"[KB Weaviate] Error searching: {e}")
- return []
-
-
-def delete_document_chunks(user_id: str, document_id: str) -> int:
- """
- Delete all chunks for a document.
-
- Args:
- user_id: User identifier
- document_id: Document identifier
-
- Returns:
- Number of deleted chunks, or -1 if an error occurred
- """
- try:
- _, collection = _get_weaviate_client()
-
- # Build filter for user_id AND document_id
- doc_filter = (
- Filter.by_property("user_id").equal(user_id)
- & Filter.by_property("document_id").equal(document_id)
- )
-
- # Delete matching objects
- result = collection.data.delete_many(where=doc_filter)
-
- deleted_count = result.successful if hasattr(result, "successful") else 0
- logger.info(
- f"[KB Weaviate] Deleted {deleted_count} chunks for doc {sanitize(document_id)}"
- )
- return deleted_count
-
- except Exception as e:
- logger.error(f"[KB Weaviate] Error deleting chunks for doc {sanitize(document_id)}: {sanitize(e)}")
- return -1 # Return -1 to distinguish error from "deleted 0 chunks"
-
-
-def delete_user_chunks(user_id: str) -> int:
- """
- Delete all chunks for a user (cleanup).
-
- Args:
- user_id: User identifier
-
- Returns:
- Number of deleted chunks, or -1 if an error occurred
- """
- try:
- _, collection = _get_weaviate_client()
-
- user_filter = Filter.by_property("user_id").equal(user_id)
- result = collection.data.delete_many(where=user_filter)
-
- deleted_count = result.successful if hasattr(result, "successful") else 0
- logger.info(f"[KB Weaviate] Deleted {deleted_count} chunks for user {sanitize(user_id)}")
- return deleted_count
-
- except Exception as e:
- logger.error(f"[KB Weaviate] Error deleting chunks for user {sanitize(user_id)}: {sanitize(e)}")
- return -1 # Return -1 to distinguish error from "deleted 0 chunks"
-
-
-def get_document_chunk_count(user_id: str, document_id: str) -> int:
- """
- Get the number of chunks for a document.
-
- Args:
- user_id: User identifier
- document_id: Document identifier
-
- Returns:
- Number of chunks
- """
- try:
- _, collection = _get_weaviate_client()
-
- doc_filter = (
- Filter.by_property("user_id").equal(user_id)
- & Filter.by_property("document_id").equal(document_id)
- )
-
- response = collection.aggregate.over_all(filters=doc_filter)
- return response.total_count if response else 0
-
- except Exception as e:
- logger.error(f"[KB Weaviate] Error getting chunk count: {e}")
- return 0
-
-
-def delete_discovery_chunks(org_id: str, before: str = None) -> int:
- """Delete auto-discovery chunks for an org, optionally only those created before a timestamp."""
- try:
- _, collection = _get_weaviate_client()
-
- from weaviate.classes.query import Filter
- discovery_filter = (
- Filter.by_property("org_id").equal(org_id)
- & Filter.by_property("document_id").like("discovery:*")
- )
- if before:
- discovery_filter = discovery_filter & Filter.by_property("created_at").less_than(before)
-
- result = collection.data.delete_many(where=discovery_filter)
- deleted = result.successful if hasattr(result, "successful") else 0
- logger.info(f"[KB Weaviate] Deleted {deleted} discovery chunks for org {org_id}")
- return deleted
-
- except Exception as e:
- logger.error(f"[KB Weaviate] Error deleting discovery chunks: {e}")
- return 0
diff --git a/server/routes/memory/__init__.py b/server/routes/memory/__init__.py
new file mode 100644
index 000000000..18a65351f
--- /dev/null
+++ b/server/routes/memory/__init__.py
@@ -0,0 +1,3 @@
+from .routes import memory_bp
+
+bp = memory_bp
diff --git a/server/routes/memory/routes.py b/server/routes/memory/routes.py
new file mode 100644
index 000000000..89e276add
--- /dev/null
+++ b/server/routes/memory/routes.py
@@ -0,0 +1,328 @@
+"""
+Memory API Routes
+
+REST endpoints for the org memory system. Memory entries live in the artifacts
+table with category in (context, runbook, infrastructure, learned, postmortem).
+Supports markdown and PDF upload (PDF → text extraction → markdown).
+"""
+
+import logging
+import io
+
+from flask import Blueprint, jsonify, request
+from pypdf import PdfReader
+
+from utils.db.connection_pool import db_pool
+from utils.auth.rbac_decorators import require_permission
+from utils.auth.stateless_auth import get_org_id_from_request, set_rls_context
+from services.memory import MEMORY_CATEGORIES
+from services.artifacts.store import create_version
+from utils.validation import strip_nul
+
+logger = logging.getLogger(__name__)
+
+memory_bp = Blueprint("memory", __name__)
+
+ALLOWED_EXTENSIONS = {"md", "txt", "pdf"}
+MAX_CONTENT_LENGTH = 500_000 # 500KB per manually-created entry
+MAX_UPLOAD_CONTENT_LENGTH = 50_000_000 # 50MB max extracted text per uploaded file
+MAX_RAW_UPLOAD_BYTES = 100_000_000 # 100MB max raw file body to prevent OOM
+
+
+def _extract_pdf_text(content: bytes) -> str:
+ """Extract text from PDF bytes using pypdf."""
+ pdf_reader = PdfReader(io.BytesIO(content))
+ text_parts = []
+ for page_num, page in enumerate(pdf_reader.pages):
+ page_text = page.extract_text()
+ if page_text and page_text.strip():
+ text_parts.append(f"[Page {page_num + 1}]\n{page_text}")
+ return "\n\n".join(text_parts)
+
+
+@memory_bp.route("/entries", methods=["GET"])
+@require_permission("memory", "read")
+def list_entries(user_id):
+ """List all memory entries for the org, optionally filtered by category."""
+ org_id = get_org_id_from_request()
+ category = request.args.get("category")
+
+ if category and category not in MEMORY_CATEGORIES:
+ return jsonify({"error": f"Invalid category. Must be one of: {', '.join(MEMORY_CATEGORIES)}"}), 400
+
+ try:
+ with db_pool.get_user_connection() as conn:
+ cursor = conn.cursor()
+ set_rls_context(cursor, conn, user_id, log_prefix="[Memory]")
+
+ if category:
+ cursor.execute(
+ """SELECT id, title, category, description, last_edited_by, updated_at
+ FROM artifacts WHERE org_id = %s AND category = %s
+ ORDER BY updated_at DESC""",
+ (org_id, category),
+ )
+ else:
+ cursor.execute(
+ """SELECT id, title, category, description, last_edited_by, updated_at
+ FROM artifacts WHERE org_id = %s AND category = ANY(%s)
+ ORDER BY category, updated_at DESC""",
+ (org_id, list(MEMORY_CATEGORIES)),
+ )
+ rows = cursor.fetchall()
+
+ entries = [
+ {
+ "id": str(row[0]),
+ "title": row[1],
+ "category": row[2],
+ "description": row[3],
+ "last_edited_by": row[4],
+ "updated_at": row[5].isoformat() if row[5] else None,
+ }
+ for row in rows
+ ]
+ return jsonify({"entries": entries}), 200
+
+ except Exception as e:
+ logger.exception(f"[Memory] Error listing entries: {e}")
+ return jsonify({"error": "Failed to list memory entries"}), 500
+
+
+@memory_bp.route("/entries", methods=["POST"])
+@require_permission("memory", "write")
+def create_entry(user_id):
+ """Create a new memory entry (JSON body with category, title, content)."""
+ org_id = get_org_id_from_request()
+ data = request.get_json(force=True, silent=True) or {}
+
+ category = data.get("category", "").strip()
+ title = data.get("title", "").strip()
+ content = data.get("content", "").strip()
+ description = data.get("description", "").strip()
+
+ if not category or category not in MEMORY_CATEGORIES:
+ return jsonify({"error": f"category must be one of: {', '.join(MEMORY_CATEGORIES)}"}), 400
+ if not title:
+ return jsonify({"error": "title is required"}), 400
+ if not content:
+ return jsonify({"error": "content is required"}), 400
+ content = strip_nul(content)
+ if len(content) > MAX_CONTENT_LENGTH:
+ return jsonify({"error": "Content exceeds 500KB limit"}), 400
+
+ try:
+ with db_pool.get_user_connection() as conn:
+ cursor = conn.cursor()
+ set_rls_context(cursor, conn, user_id, log_prefix="[Memory]")
+
+ # Set category and description via direct insert with the upsert
+ cursor.execute(
+ """INSERT INTO artifacts
+ (org_id, user_id, title, content, category, description,
+ last_edited_by, updated_at)
+ VALUES (%s, %s, %s, %s, %s, %s, 'user', CURRENT_TIMESTAMP)
+ ON CONFLICT (org_id, category, title)
+ DO UPDATE SET content = EXCLUDED.content,
+ description = EXCLUDED.description,
+ last_edited_by = 'user',
+ updated_at = CURRENT_TIMESTAMP
+ RETURNING id""",
+ (org_id, user_id, title, content, category, description or None),
+ )
+ row = cursor.fetchone()
+ artifact_id = str(row[0])
+
+ version = create_version(
+ cursor, artifact_id, org_id, user_id, content,
+ source="manual", set_current=True,
+ )
+ conn.commit()
+
+ return jsonify({"id": artifact_id, "version": version}), 201
+
+ except Exception as e:
+ logger.exception(f"[Memory] Error creating entry: {e}")
+ return jsonify({"error": "Failed to create memory entry"}), 500
+
+
+@memory_bp.route("/entries/", methods=["GET"])
+@require_permission("memory", "read")
+def get_entry(user_id, entry_id):
+ """Get a single memory entry by ID."""
+ org_id = get_org_id_from_request()
+
+ try:
+ with db_pool.get_user_connection() as conn:
+ cursor = conn.cursor()
+ set_rls_context(cursor, conn, user_id, log_prefix="[Memory]")
+
+ cursor.execute(
+ """SELECT id, title, category, description, content,
+ last_edited_by, updated_at
+ FROM artifacts WHERE id = %s AND org_id = %s AND category = ANY(%s)""",
+ (entry_id, org_id, list(MEMORY_CATEGORIES)),
+ )
+ row = cursor.fetchone()
+
+ if not row:
+ return jsonify({"error": "Memory entry not found"}), 404
+
+ return jsonify({
+ "id": str(row[0]),
+ "title": row[1],
+ "category": row[2],
+ "description": row[3],
+ "content": row[4],
+ "last_edited_by": row[5],
+ "updated_at": row[6].isoformat() if row[6] else None,
+ }), 200
+
+ except Exception as e:
+ logger.exception(f"[Memory] Error getting entry: {e}")
+ return jsonify({"error": "Failed to get memory entry"}), 500
+
+
+@memory_bp.route("/entries/", methods=["DELETE"])
+@require_permission("memory", "write")
+def delete_entry(user_id, entry_id):
+ """Delete a memory entry."""
+ org_id = get_org_id_from_request()
+
+ try:
+ with db_pool.get_user_connection() as conn:
+ cursor = conn.cursor()
+ set_rls_context(cursor, conn, user_id, log_prefix="[Memory]")
+
+ cursor.execute(
+ "DELETE FROM artifacts WHERE id = %s AND org_id = %s AND category = ANY(%s)",
+ (entry_id, org_id, list(MEMORY_CATEGORIES)),
+ )
+ if cursor.rowcount == 0:
+ return jsonify({"error": "Memory entry not found"}), 404
+ conn.commit()
+
+ return jsonify({"success": True}), 200
+
+ except Exception as e:
+ logger.exception(f"[Memory] Error deleting entry: {e}")
+ return jsonify({"error": "Failed to delete memory entry"}), 500
+
+
+@memory_bp.route("/entries/", methods=["PUT"])
+@require_permission("memory", "write")
+def update_entry(user_id, entry_id):
+ """Update a memory entry's category."""
+ org_id = get_org_id_from_request()
+ data = request.get_json(silent=True) or {}
+ category = data.get("category", "").strip()
+
+ if not category or category not in MEMORY_CATEGORIES:
+ return jsonify({"error": f"Invalid category. Must be one of: {', '.join(MEMORY_CATEGORIES)}"}), 400
+
+ try:
+ with db_pool.get_user_connection() as conn:
+ cursor = conn.cursor()
+ set_rls_context(cursor, conn, user_id, log_prefix="[Memory]")
+
+ cursor.execute(
+ """UPDATE artifacts
+ SET category = %s, last_edited_by = 'user', updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s AND org_id = %s AND category = ANY(%s)""",
+ (category, entry_id, org_id, list(MEMORY_CATEGORIES)),
+ )
+ if cursor.rowcount == 0:
+ return jsonify({"error": "Memory entry not found"}), 404
+ conn.commit()
+
+ return jsonify({"success": True}), 200
+
+ except Exception as e:
+ logger.exception(f"[Memory] Error updating entry: {e}")
+ return jsonify({"error": "Failed to update memory entry"}), 500
+
+
+@memory_bp.route("/upload", methods=["POST"])
+@require_permission("memory", "write")
+def upload_file(user_id):
+ """Upload a .md, .txt, or .pdf file as a memory entry."""
+ org_id = get_org_id_from_request()
+
+ if "file" not in request.files:
+ return jsonify({"error": "No file provided"}), 400
+
+ file = request.files["file"]
+ if not file.filename:
+ return jsonify({"error": "No file selected"}), 400
+
+ ext = file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else ""
+ if ext not in ALLOWED_EXTENSIONS:
+ return jsonify({"error": f"File type not allowed. Supported: {', '.join(ALLOWED_EXTENSIONS)}"}), 400
+
+ category = request.form.get("category", "runbook").strip()
+ if category not in MEMORY_CATEGORIES:
+ return jsonify({"error": f"category must be one of: {', '.join(MEMORY_CATEGORIES)}"}), 400
+
+ try:
+ # Check Content-Length header to reject oversized uploads before reading
+ content_length = request.content_length
+ if content_length and content_length > MAX_RAW_UPLOAD_BYTES:
+ return jsonify({"error": "File too large. Maximum raw upload size is 100MB."}), 400
+
+ raw_bytes = file.read(MAX_RAW_UPLOAD_BYTES + 1)
+ if len(raw_bytes) > MAX_RAW_UPLOAD_BYTES:
+ return jsonify({"error": "File too large. Maximum raw upload size is 100MB."}), 400
+
+ # Extract text based on file type
+ if ext == "pdf":
+ content = _extract_pdf_text(raw_bytes)
+ else:
+ content = raw_bytes.decode("utf-8", errors="replace")
+
+ # Strip NUL bytes that Postgres text columns reject
+ content = strip_nul(content)
+
+ if not content.strip():
+ return jsonify({"error": "No text content could be extracted from file"}), 400
+
+ if len(content) > MAX_UPLOAD_CONTENT_LENGTH:
+ return jsonify({"error": "Extracted text exceeds 50MB limit."}), 400
+
+ # Use explicit title if provided, otherwise derive from filename
+ base_title = request.form.get("title", "").strip()
+ if not base_title:
+ base_title = file.filename.rsplit(".", 1)[0] if "." in file.filename else file.filename
+ description = request.form.get("description", "").strip()
+
+ with db_pool.get_user_connection() as conn:
+ cursor = conn.cursor()
+ set_rls_context(cursor, conn, user_id, log_prefix="[Memory]")
+
+ cursor.execute(
+ """INSERT INTO artifacts
+ (org_id, user_id, title, content, category, description,
+ last_edited_by, updated_at)
+ VALUES (%s, %s, %s, %s, %s, %s, 'user', CURRENT_TIMESTAMP)
+ ON CONFLICT (org_id, category, title)
+ DO UPDATE SET content = EXCLUDED.content,
+ description = EXCLUDED.description,
+ last_edited_by = 'user',
+ updated_at = CURRENT_TIMESTAMP
+ RETURNING id""",
+ (org_id, user_id, base_title, content, category, description or None),
+ )
+ row = cursor.fetchone()
+ artifact_id = str(row[0])
+
+ create_version(
+ cursor, artifact_id, org_id, user_id, content,
+ source="manual", set_current=True,
+ )
+ conn.commit()
+
+ logger.info("[Memory] Uploaded file (%d chars)", len(content))
+ return jsonify({"entries": [{"id": artifact_id, "title": base_title}], "parts": 1}), 201
+
+ except Exception as e:
+ logger.exception("[Memory] Error uploading file")
+ return jsonify({"error": "Failed to upload file"}), 500
diff --git a/server/routes/postmortem_routes.py b/server/routes/postmortem_routes.py
index b6b1eb097..b5532e108 100644
--- a/server/routes/postmortem_routes.py
+++ b/server/routes/postmortem_routes.py
@@ -1,4 +1,8 @@
-"""API routes for postmortem CRUD operations and Confluence export."""
+"""API routes for postmortem CRUD operations and exports.
+
+Backed by the `artifacts` / `artifact_versions` tables (category='postmortem').
+Export metadata lives in `postmortem_exports`.
+"""
import logging
import time
@@ -13,6 +17,7 @@
ConfluenceClient,
markdown_to_confluence_storage,
)
+from services.artifacts.store import create_version
from utils.auth.token_management import get_token_data, store_tokens_in_db
from utils.auth.rbac_decorators import require_permission
from utils.auth.stateless_auth import get_org_id_from_request, set_rls_context
@@ -20,47 +25,20 @@
from utils.db.connection_pool import db_pool
from utils.log_sanitizer import sanitize
from utils.query_helpers import iso_utc
-from utils.validation import is_valid_uuid
+from utils.validation import is_valid_uuid, strip_nul
logger = logging.getLogger(__name__)
postmortem_bp = Blueprint("postmortem", __name__)
_LOG_PREFIX = "[Postmortem]"
-
-def _create_version(
- cursor, postmortem_id: str, org_id: str, user_id: str, content: str,
- source: str = "manual", *, set_current: bool = True,
-) -> int:
- """Insert a new version row for a postmortem atomically and return the version number.
-
- When set_current=True (default), also advances the current_version_id pointer.
- Snapshot versions (e.g. pre_edit) should pass set_current=False.
- """
- cursor.execute(
- """INSERT INTO postmortem_versions
- (postmortem_id, org_id, user_id, content, version_number, source)
- VALUES (%s, %s, %s, %s,
- (SELECT COALESCE(MAX(version_number), 0) + 1
- FROM postmortem_versions WHERE postmortem_id = %s),
- %s)
- RETURNING id, version_number""",
- (postmortem_id, org_id, user_id, content, postmortem_id, source),
- )
- row = cursor.fetchone()
- version_id, version_number = row[0], row[1]
- if set_current:
- cursor.execute(
- "UPDATE postmortems SET current_version_id = %s WHERE id = %s",
- (str(version_id), postmortem_id),
- )
- return version_number
+_CATEGORY = "postmortem"
def with_incident_postmortem(require_postmortem=False):
"""Decorator that validates incident_id, resolves org_id, opens DB, sets RLS.
- Injects keyword args: org_id, conn, cursor, postmortem_id (if require_postmortem).
+ Injects keyword args: org_id, conn, cursor, artifact_id (if require_postmortem).
"""
def decorator(fn):
@wraps(fn)
@@ -75,22 +53,23 @@ def wrapper(user_id, incident_id, *args, **kwargs):
with conn.cursor() as cursor:
set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX)
- postmortem_id = None
+ artifact_id = None
if require_postmortem:
cursor.execute(
- """SELECT id FROM postmortems
- WHERE incident_id = %s AND org_id = %s""",
- (incident_id, org_id),
+ """SELECT id FROM artifacts
+ WHERE incident_id = %s AND org_id = %s
+ AND category = %s""",
+ (incident_id, org_id, _CATEGORY),
)
row = cursor.fetchone()
if not row:
return jsonify({"error": "Postmortem not found"}), 404
- postmortem_id = str(row[0])
+ artifact_id = str(row[0])
return fn(
user_id, incident_id, *args,
org_id=org_id, conn=conn, cursor=cursor,
- postmortem_id=postmortem_id, **kwargs,
+ postmortem_id=artifact_id, **kwargs,
)
except Exception as e:
@@ -137,50 +116,125 @@ def _refresh_confluence_credentials(user_id: str, creds: Dict[str, Any]) -> Opti
return updated_creds
+def _refresh_jira_credentials(user_id: str, creds: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Attempt to refresh OAuth Jira credentials."""
+ from connectors.atlassian_auth.auth import refresh_access_token as _refresh_token
+
+ refresh_token = creds.get("refresh_token")
+ if not refresh_token:
+ return None
+ try:
+ token_data = _refresh_token(refresh_token)
+ except Exception as exc:
+ logger.warning("[POSTMORTEM] Jira OAuth refresh failed for user %s: %s", user_id, exc)
+ return None
+
+ access_token = token_data.get("access_token")
+ if not access_token:
+ return None
+
+ updated = dict(creds)
+ updated["access_token"] = access_token
+ new_refresh = token_data.get("refresh_token")
+ if new_refresh:
+ updated["refresh_token"] = new_refresh
+ expires_in = token_data.get("expires_in")
+ if expires_in:
+ updated["expires_in"] = expires_in
+ updated["expires_at"] = int(time.time()) + int(expires_in)
+
+ store_tokens_in_db(user_id, updated, "jira")
+ return updated
+
+
+def _build_postmortem_response(artifact_row, exports):
+ """Build the standard postmortem response dict from an artifact row and exports map."""
+ confluence = exports.get("confluence", {})
+ jira = exports.get("jira", {})
+ notion = exports.get("notion", {})
+
+ return {
+ "id": str(artifact_row["id"]),
+ "incidentId": str(artifact_row["incident_id"]),
+ "userId": artifact_row["user_id"],
+ "content": artifact_row["content"],
+ "generatedAt": iso_utc(artifact_row["created_at"]),
+ "updatedAt": iso_utc(artifact_row["updated_at"]),
+ "confluencePageId": confluence.get("external_id"),
+ "confluencePageUrl": confluence.get("external_url"),
+ "confluenceExportedAt": iso_utc(confluence.get("exported_at")),
+ "jiraIssueId": jira.get("external_id"),
+ "jiraIssueKey": jira.get("external_key"),
+ "jiraIssueUrl": jira.get("external_url"),
+ "jiraExportedAt": iso_utc(jira.get("exported_at")),
+ "notionPageId": notion.get("external_id"),
+ "notionPageUrl": notion.get("external_url"),
+ "notionExportedAt": iso_utc(notion.get("exported_at")),
+ "notionDatabaseId": notion.get("external_database_id"),
+ "generationSessionId": artifact_row["generation_session_id"],
+ }
+
+
+def _fetch_exports(cursor, artifact_id):
+ """Fetch postmortem_exports rows for an artifact, keyed by destination."""
+ cursor.execute(
+ """SELECT destination, external_id, external_key, external_url,
+ external_database_id, exported_at
+ FROM postmortem_exports
+ WHERE postmortem_id = %s""",
+ (artifact_id,),
+ )
+ exports = {}
+ for row in cursor.fetchall():
+ exports[row[0]] = {
+ "external_id": row[1],
+ "external_key": row[2],
+ "external_url": row[3],
+ "external_database_id": row[4],
+ "exported_at": row[5],
+ }
+ return exports
+
+
+# ---------------------------------------------------------------------------
+# GET / PATCH single postmortem
+# ---------------------------------------------------------------------------
+
+
@postmortem_bp.route("/api/incidents//postmortem", methods=["GET"])
@require_permission("postmortems", "read")
@with_incident_postmortem(require_postmortem=False)
def get_postmortem(user_id, incident_id, *, org_id, conn, cursor, postmortem_id, **kwargs):
cursor.execute(
- """SELECT id, incident_id, user_id, content, generated_at, updated_at,
- confluence_page_id, confluence_page_url, confluence_exported_at,
- jira_issue_id, jira_issue_key, jira_issue_url, jira_exported_at,
- notion_page_id, notion_page_url, notion_exported_at, notion_database_id,
+ """SELECT id, incident_id, user_id, content, created_at, updated_at,
generation_session_id
- FROM postmortems
- WHERE incident_id = %s AND org_id = %s""",
- (incident_id, org_id),
+ FROM artifacts
+ WHERE incident_id = %s AND org_id = %s AND category = %s""",
+ (incident_id, org_id, _CATEGORY),
)
row = cursor.fetchone()
if not row:
return jsonify({"error": "Postmortem not found"}), 404
- # Row exists but content is NULL → generation in progress
+ # Row exists but content is NULL -> generation in progress
if row[3] is None:
- return jsonify({"status": "generating", "generationSessionId": row[17]}), 202
+ return jsonify({"status": "generating", "generationSessionId": row[6]}), 202
- postmortem = {
- "id": str(row[0]),
- "incidentId": str(row[1]),
- "userId": row[2],
+ artifact_id = str(row[0])
+ artifact_row = {
+ "id": row[0],
+ "incident_id": row[1],
+ "user_id": row[2],
"content": row[3],
- "generatedAt": iso_utc(row[4]),
- "updatedAt": iso_utc(row[5]),
- "confluencePageId": row[6],
- "confluencePageUrl": row[7],
- "confluenceExportedAt": iso_utc(row[8]),
- "jiraIssueId": row[9],
- "jiraIssueKey": row[10],
- "jiraIssueUrl": row[11],
- "jiraExportedAt": iso_utc(row[12]),
- "notionPageId": row[13],
- "notionPageUrl": row[14],
- "notionExportedAt": iso_utc(row[15]),
- "notionDatabaseId": row[16],
- "generationSessionId": row[17],
+ "created_at": row[4],
+ "updated_at": row[5],
+ "generation_session_id": row[6],
}
+
+ exports = _fetch_exports(cursor, artifact_id)
+ postmortem = _build_postmortem_response(artifact_row, exports)
return jsonify({"postmortem": postmortem})
@@ -203,14 +257,19 @@ def update_postmortem(user_id, incident_id, *, org_id, conn, cursor, postmortem_
{"error": "Content exceeds maximum length of 100000 characters"}
), 400
+ content = strip_nul(content)
+
# Snapshot the pre-edit content into version history
- cursor.execute("SELECT content FROM postmortems WHERE id = %s", (postmortem_id,))
+ cursor.execute("SELECT content FROM artifacts WHERE id = %s", (postmortem_id,))
prev_row = cursor.fetchone()
if prev_row and prev_row[0]:
- _create_version(cursor, postmortem_id, org_id, user_id, prev_row[0], source="pre_edit", set_current=False)
+ create_version(
+ cursor, postmortem_id, org_id, user_id, prev_row[0],
+ source="pre_edit", set_current=False,
+ )
cursor.execute(
- """UPDATE postmortems
+ """UPDATE artifacts
SET content = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(content, postmortem_id),
@@ -221,19 +280,24 @@ def update_postmortem(user_id, incident_id, *, org_id, conn, cursor, postmortem_
return jsonify({"success": True})
+# ---------------------------------------------------------------------------
+# Version history endpoints
+# ---------------------------------------------------------------------------
+
+
@postmortem_bp.route("/api/incidents//postmortem/versions", methods=["GET"])
@require_permission("postmortems", "read")
@with_incident_postmortem(require_postmortem=False)
def list_postmortem_versions(user_id, incident_id, *, org_id, conn, cursor, postmortem_id, **kwargs):
"""List version history for a postmortem."""
cursor.execute(
- """SELECT v.id, v.version_number, v.source, v.user_id, v.created_at, v.generation_session_id,
- p.current_version_id
- FROM postmortem_versions v
- JOIN postmortems p ON v.postmortem_id = p.id
- WHERE p.incident_id = %s AND p.org_id = %s
+ """SELECT v.id, v.version_number, v.source, v.user_id, v.created_at,
+ v.generation_session_id, a.current_version_id
+ FROM artifact_versions v
+ JOIN artifacts a ON v.artifact_id = a.id
+ WHERE a.incident_id = %s AND a.org_id = %s AND a.category = %s
ORDER BY v.version_number DESC""",
- (incident_id, org_id),
+ (incident_id, org_id, _CATEGORY),
)
rows = cursor.fetchall()
@@ -263,10 +327,10 @@ def get_postmortem_version(user_id, incident_id, version_id, *, org_id, conn, cu
cursor.execute(
"""SELECT v.id, v.version_number, v.source, v.user_id, v.content, v.created_at
- FROM postmortem_versions v
- JOIN postmortems p ON v.postmortem_id = p.id
- WHERE v.id = %s AND p.incident_id = %s AND p.org_id = %s""",
- (version_id, incident_id, org_id),
+ FROM artifact_versions v
+ JOIN artifacts a ON v.artifact_id = a.id
+ WHERE v.id = %s AND a.incident_id = %s AND a.org_id = %s AND a.category = %s""",
+ (version_id, incident_id, org_id, _CATEGORY),
)
row = cursor.fetchone()
@@ -295,10 +359,10 @@ def restore_postmortem_version(user_id, incident_id, version_id, *, org_id, conn
cursor.execute(
"""SELECT v.content
- FROM postmortem_versions v
- JOIN postmortems p ON v.postmortem_id = p.id
- WHERE v.id = %s AND p.incident_id = %s AND p.org_id = %s""",
- (version_id, incident_id, org_id),
+ FROM artifact_versions v
+ JOIN artifacts a ON v.artifact_id = a.id
+ WHERE v.id = %s AND a.incident_id = %s AND a.org_id = %s AND a.category = %s""",
+ (version_id, incident_id, org_id, _CATEGORY),
)
row = cursor.fetchone()
if not row:
@@ -306,9 +370,8 @@ def restore_postmortem_version(user_id, incident_id, version_id, *, org_id, conn
restored_content = row[0]
- # Point to the restored version and update content
cursor.execute(
- """UPDATE postmortems
+ """UPDATE artifacts
SET content = %s, current_version_id = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(restored_content, version_id, postmortem_id),
@@ -320,6 +383,11 @@ def restore_postmortem_version(user_id, incident_id, version_id, *, org_id, conn
return jsonify({"success": True, "content": restored_content})
+# ---------------------------------------------------------------------------
+# Regenerate
+# ---------------------------------------------------------------------------
+
+
@postmortem_bp.route("/api/incidents//postmortem/regenerate", methods=["POST"])
@require_permission("postmortems", "write")
def regenerate_postmortem(user_id, incident_id):
@@ -334,7 +402,7 @@ def regenerate_postmortem(user_id, incident_id):
except ValueError as e:
if "Rate limited" in str(e):
- return jsonify({"error": "Rate limited — try again later"}), 429
+ return jsonify({"error": "Rate limited \u2014 try again later"}), 429
if "already running" in str(e):
return jsonify({"error": "Generation already in progress"}), 409
return jsonify({"error": "Unable to generate postmortem"}), 400
@@ -347,6 +415,11 @@ def regenerate_postmortem(user_id, incident_id):
return jsonify({"error": "Failed to regenerate postmortem"}), 500
+# ---------------------------------------------------------------------------
+# Export to Confluence
+# ---------------------------------------------------------------------------
+
+
@postmortem_bp.route(
"/api/incidents//postmortem/export/confluence", methods=["POST"]
)
@@ -369,15 +442,14 @@ def export_to_confluence(user_id, incident_id):
org_id = get_org_id_from_request()
- # Fetch postmortem content from DB
try:
with db_pool.get_admin_connection() as conn:
with conn.cursor() as cursor:
set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX)
cursor.execute(
- """SELECT id, content FROM postmortems
- WHERE incident_id = %s AND org_id = %s""",
- (incident_id, org_id),
+ """SELECT id, content FROM artifacts
+ WHERE incident_id = %s AND org_id = %s AND category = %s""",
+ (incident_id, org_id, _CATEGORY),
)
row = cursor.fetchone()
except Exception as e:
@@ -391,13 +463,12 @@ def export_to_confluence(user_id, incident_id):
if not row:
return jsonify({"error": "Postmortem not found"}), 404
- postmortem_id = row[0]
+ artifact_id = str(row[0])
content = row[1]
if not content:
return jsonify({"error": "Postmortem has no content to export"}), 400
- # Get Confluence credentials
creds = get_token_data(user_id, "confluence")
if not creds:
return jsonify({"error": "Confluence not connected"}), 404
@@ -411,13 +482,9 @@ def export_to_confluence(user_id, incident_id):
cloud_id = creds.get("cloud_id") if auth_type == "oauth" else None
- # Convert markdown to Confluence storage format
content_html = markdown_to_confluence_storage(content)
-
- # Build page title from first line or fallback
title = f"Postmortem - Incident {incident_id[:8]}"
- # Create page on Confluence
try:
client = ConfluenceClient(
base_url, token, auth_type=auth_type, cloud_id=cloud_id
@@ -469,12 +536,10 @@ def export_to_confluence(user_id, incident_id):
)
return jsonify({"error": "Invalid response from Confluence"}), 502
- # Update postmortem record with Confluence details
try:
with db_pool.get_admin_connection() as conn:
with conn.cursor() as cursor:
set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX)
- # Write to normalized exports table
cursor.execute(
"""INSERT INTO postmortem_exports
(postmortem_id, org_id, destination, external_id, external_url, exported_at)
@@ -483,31 +548,26 @@ def export_to_confluence(user_id, incident_id):
DO UPDATE SET external_id = EXCLUDED.external_id,
external_url = EXCLUDED.external_url,
exported_at = EXCLUDED.exported_at""",
- (str(postmortem_id), org_id, str(page_id), page_url),
- )
- # Keep legacy columns in sync
- cursor.execute(
- """UPDATE postmortems
- SET confluence_page_id = %s,
- confluence_page_url = %s,
- confluence_exported_at = CURRENT_TIMESTAMP
- WHERE id = %s AND org_id = %s""",
- (str(page_id), page_url, str(postmortem_id), org_id),
+ (artifact_id, org_id, str(page_id), page_url),
)
conn.commit()
except Exception as e:
logger.warning(
- "[POSTMORTEM] Failed to update Confluence metadata for postmortem %s: %s",
- postmortem_id,
+ "[POSTMORTEM] Failed to update Confluence metadata for artifact %s: %s",
+ artifact_id,
e,
)
- # Still return success since the page was created
record_audit_event(org_id, user_id, "export_postmortem_confluence", "postmortem", incident_id,
{"page_url": page_url}, request)
return jsonify({"success": True, "pageUrl": page_url, "pageId": str(page_id)})
+# ---------------------------------------------------------------------------
+# Export to Notion
+# ---------------------------------------------------------------------------
+
+
@postmortem_bp.route(
"/api/incidents//postmortem/export/notion", methods=["POST"]
)
@@ -547,15 +607,12 @@ def export_to_notion(user_id, incident_id):
except NotionAuthExpiredError:
return jsonify({
"code": "reauth_required",
- "error": "Notion credentials expired — please reconnect",
+ "error": "Notion credentials expired \u2014 please reconnect",
}), 401
except ValueError as exc:
logger.warning(
"[POSTMORTEM] Notion export rejected for user %s: %s", user_id, exc
)
- # Surface only the explicit message arg we raise in the helper; never
- # echo the exception object directly so stack-trace-bearing values
- # (from any downstream library) can't flow to the client.
safe_msg = (
exc.args[0]
if exc.args and isinstance(exc.args[0], str)
@@ -566,7 +623,7 @@ def export_to_notion(user_id, incident_id):
logger.exception(
"[POSTMORTEM] Notion export partially failed for user %s: %s", user_id, exc
)
- return jsonify({"error": "Notion page created but content write failed — check Notion and retry"}), 502
+ return jsonify({"error": "Notion page created but content write failed \u2014 check Notion and retry"}), 502
except Exception as exc:
logger.exception(
"[POSTMORTEM] Notion export failed for user %s: %s", user_id, exc
@@ -576,69 +633,9 @@ def export_to_notion(user_id, incident_id):
return jsonify(result)
-@postmortem_bp.route("/api/postmortems", methods=["GET"])
-@require_permission("postmortems", "read")
-def list_postmortems(user_id):
-
- try:
- limit = min(int(request.args.get("limit", 50)), 100)
- offset = max(int(request.args.get("offset", 0)), 0)
- except (ValueError, TypeError):
- limit, offset = 50, 0
-
- org_id = get_org_id_from_request()
-
- try:
- with db_pool.get_admin_connection() as conn:
- with conn.cursor() as cursor:
- set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX)
- cursor.execute(
- """SELECT p.id, p.incident_id, p.user_id, p.content, p.generated_at, p.updated_at,
- p.confluence_page_id, p.confluence_page_url, p.confluence_exported_at,
- i.alert_title,
- p.jira_issue_id, p.jira_issue_key, p.jira_issue_url, p.jira_exported_at,
- p.notion_page_id, p.notion_page_url, p.notion_exported_at, p.notion_database_id
- FROM postmortems p
- LEFT JOIN incidents i ON p.incident_id = i.id
- WHERE p.org_id = %s
- ORDER BY p.generated_at DESC
- LIMIT %s OFFSET %s""",
- (org_id, limit, offset),
- )
- rows = cursor.fetchall()
-
- postmortems = []
- for row in rows:
- postmortem = {
- "id": str(row[0]),
- "incidentId": str(row[1]),
- "incidentTitle": row[9],
- "content": row[3],
- "generatedAt": iso_utc(row[4]),
- "updatedAt": iso_utc(row[5]),
- "confluencePageId": row[6],
- "confluencePageUrl": row[7],
- "confluenceExportedAt": iso_utc(row[8]),
- "jiraIssueId": row[10],
- "jiraIssueKey": row[11],
- "jiraIssueUrl": row[12],
- "jiraExportedAt": iso_utc(row[13]),
- "notionPageId": row[14],
- "notionPageUrl": row[15],
- "notionExportedAt": iso_utc(row[16]),
- "notionDatabaseId": row[17],
- }
- postmortems.append(postmortem)
-
- return jsonify({"postmortems": postmortems})
-
- except Exception as e:
- logger.error(
- "[POSTMORTEM] Failed to fetch postmortems for user %s: %s",
- user_id,
- e,
- )
- return jsonify({"error": "Failed to fetch postmortems"}), 500
+# ---------------------------------------------------------------------------
+# Export to Jira
+# ---------------------------------------------------------------------------
@postmortem_bp.route(
@@ -668,9 +665,9 @@ def export_to_jira(user_id, incident_id):
with conn.cursor() as cursor:
set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX)
cursor.execute(
- """SELECT id, content FROM postmortems
- WHERE incident_id = %s AND org_id = %s""",
- (incident_id, org_id),
+ """SELECT id, content FROM artifacts
+ WHERE incident_id = %s AND org_id = %s AND category = %s""",
+ (incident_id, org_id, _CATEGORY),
)
row = cursor.fetchone()
except Exception as e:
@@ -684,7 +681,7 @@ def export_to_jira(user_id, incident_id):
if not row:
return jsonify({"error": "Postmortem not found"}), 404
- postmortem_id = row[0]
+ artifact_id = str(row[0])
content = row[1]
if not content:
@@ -772,7 +769,6 @@ def export_to_jira(user_id, incident_id):
with db_pool.get_admin_connection() as conn:
with conn.cursor() as cursor:
set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX)
- # Write to normalized exports table
cursor.execute(
"""INSERT INTO postmortem_exports
(postmortem_id, org_id, destination, external_id, external_key, external_url, exported_at)
@@ -782,21 +778,11 @@ def export_to_jira(user_id, incident_id):
external_key = EXCLUDED.external_key,
external_url = EXCLUDED.external_url,
exported_at = EXCLUDED.exported_at""",
- (str(postmortem_id), org_id, str(parent_id), parent_key, parent_url),
- )
- # Keep legacy columns in sync
- cursor.execute(
- """UPDATE postmortems
- SET jira_issue_id = %s,
- jira_issue_key = %s,
- jira_issue_url = %s,
- jira_exported_at = CURRENT_TIMESTAMP
- WHERE id = %s AND org_id = %s""",
- (str(parent_id), parent_key, parent_url, str(postmortem_id), org_id),
+ (artifact_id, org_id, str(parent_id), parent_key, parent_url),
)
conn.commit()
except Exception as e:
- logger.warning("[POSTMORTEM] Failed to update Jira metadata for postmortem %s: %s", postmortem_id, e)
+ logger.warning("[POSTMORTEM] Failed to update Jira metadata for artifact %s: %s", artifact_id, e)
record_audit_event(org_id, user_id, "export_postmortem_jira", "postmortem", incident_id,
{"issue_key": parent_key, "issue_url": parent_url}, request)
@@ -810,32 +796,95 @@ def export_to_jira(user_id, incident_id):
})
-def _refresh_jira_credentials(user_id: str, creds: Dict[str, Any]) -> Optional[Dict[str, Any]]:
- """Attempt to refresh OAuth Jira credentials."""
- from connectors.atlassian_auth.auth import refresh_access_token as _refresh_token
+# ---------------------------------------------------------------------------
+# List all postmortems
+# ---------------------------------------------------------------------------
+
+
+@postmortem_bp.route("/api/postmortems", methods=["GET"])
+@require_permission("postmortems", "read")
+def list_postmortems(user_id):
- refresh_token = creds.get("refresh_token")
- if not refresh_token:
- return None
try:
- token_data = _refresh_token(refresh_token)
- except Exception as exc:
- logger.warning("[POSTMORTEM] Jira OAuth refresh failed for user %s: %s", user_id, exc)
- return None
+ limit = min(int(request.args.get("limit", 50)), 100)
+ offset = max(int(request.args.get("offset", 0)), 0)
+ except (ValueError, TypeError):
+ limit, offset = 50, 0
- access_token = token_data.get("access_token")
- if not access_token:
- return None
+ org_id = get_org_id_from_request()
- updated = dict(creds)
- updated["access_token"] = access_token
- new_refresh = token_data.get("refresh_token")
- if new_refresh:
- updated["refresh_token"] = new_refresh
- expires_in = token_data.get("expires_in")
- if expires_in:
- updated["expires_in"] = expires_in
- updated["expires_at"] = int(time.time()) + int(expires_in)
+ try:
+ with db_pool.get_admin_connection() as conn:
+ with conn.cursor() as cursor:
+ set_rls_context(cursor, conn, user_id, log_prefix=_LOG_PREFIX)
- store_tokens_in_db(user_id, updated, "jira")
- return updated
\ No newline at end of file
+ cursor.execute(
+ """SELECT a.id, a.incident_id, a.user_id, a.content,
+ a.created_at, a.updated_at, i.alert_title
+ FROM artifacts a
+ LEFT JOIN incidents i ON a.incident_id = i.id
+ WHERE a.org_id = %s AND a.category = %s
+ ORDER BY a.created_at DESC
+ LIMIT %s OFFSET %s""",
+ (org_id, _CATEGORY, limit, offset),
+ )
+ artifact_rows = cursor.fetchall()
+
+ # Batch-fetch exports for all artifacts in the result set
+ artifact_ids = [str(r[0]) for r in artifact_rows]
+ exports_map: Dict[str, Dict] = {aid: {} for aid in artifact_ids}
+
+ if artifact_ids:
+ cursor.execute(
+ """SELECT postmortem_id, destination, external_id, external_key,
+ external_url, external_database_id, exported_at
+ FROM postmortem_exports
+ WHERE postmortem_id = ANY(%s::uuid[])""",
+ (artifact_ids,),
+ )
+ for erow in cursor.fetchall():
+ exports_map.setdefault(str(erow[0]), {})[erow[1]] = {
+ "external_id": erow[2],
+ "external_key": erow[3],
+ "external_url": erow[4],
+ "external_database_id": erow[5],
+ "exported_at": erow[6],
+ }
+
+ postmortems = []
+ for row in artifact_rows:
+ aid = str(row[0])
+ exp = exports_map.get(aid, {})
+ confluence = exp.get("confluence", {})
+ jira = exp.get("jira", {})
+ notion = exp.get("notion", {})
+
+ postmortems.append({
+ "id": aid,
+ "incidentId": str(row[1]),
+ "incidentTitle": row[6],
+ "content": row[3],
+ "generatedAt": iso_utc(row[4]),
+ "updatedAt": iso_utc(row[5]),
+ "confluencePageId": confluence.get("external_id"),
+ "confluencePageUrl": confluence.get("external_url"),
+ "confluenceExportedAt": iso_utc(confluence.get("exported_at")),
+ "jiraIssueId": jira.get("external_id"),
+ "jiraIssueKey": jira.get("external_key"),
+ "jiraIssueUrl": jira.get("external_url"),
+ "jiraExportedAt": iso_utc(jira.get("exported_at")),
+ "notionPageId": notion.get("external_id"),
+ "notionPageUrl": notion.get("external_url"),
+ "notionExportedAt": iso_utc(notion.get("exported_at")),
+ "notionDatabaseId": notion.get("external_database_id"),
+ })
+
+ return jsonify({"postmortems": postmortems})
+
+ except Exception as e:
+ logger.error(
+ "[POSTMORTEM] Failed to fetch postmortems for user %s: %s",
+ user_id,
+ e,
+ )
+ return jsonify({"error": "Failed to fetch postmortems"}), 500
diff --git a/server/services/actions/postmortem_action.py b/server/services/actions/postmortem_action.py
index 2cf95c1cf..bf546fd71 100644
--- a/server/services/actions/postmortem_action.py
+++ b/server/services/actions/postmortem_action.py
@@ -78,13 +78,13 @@ def dispatch_postmortem_action(
Raises:
ValueError: If rate limited or generation already in progress
"""
- # Check for active generation (row with NULL content = in progress)
+ # Check for active generation (artifact with NULL content = in progress)
with db_pool.get_connection() as conn:
with conn.cursor() as cur:
set_rls_context(cur, conn, user_id, log_prefix="[PostmortemAction]")
cur.execute(
- """SELECT id FROM postmortems
- WHERE incident_id = %s AND content IS NULL""",
+ """SELECT id FROM artifacts
+ WHERE incident_id = %s AND category = 'postmortem' AND content IS NULL""",
(incident_id,),
)
if cur.fetchone():
@@ -139,8 +139,8 @@ def dispatch_postmortem_action(
trigger_metadata=trigger_meta,
)
- # Pre-create the postmortem row so the GET endpoint can detect "generating" state
- _reserve_postmortem_row(user_id, incident_id, session_id)
+ # Pre-create the artifact row so the GET endpoint can detect "generating" state
+ _reserve_postmortem_artifact(user_id, incident_id, session_id)
if run_id:
_update_run(run_id, user_id, chat_session_id=session_id)
@@ -245,13 +245,13 @@ def _build_action_prompt(instructions: str, incident: dict) -> str:
return "\n".join(parts)
-def _reserve_postmortem_row(user_id: str, incident_id: str, session_id: str) -> None:
- """Pre-create a postmortem row with NULL content to signal 'generating' state.
+def _reserve_postmortem_artifact(user_id: str, incident_id: str, session_id: str) -> None:
+ """Pre-create an artifact row with NULL content to signal 'generating' state.
- If a postmortem already exists (regeneration), sets content to NULL so the
- GET endpoint returns 202. Previous content is already preserved in
- postmortem_versions from the original save.
- The save_postmortem tool will later fill in the content via ON CONFLICT UPDATE.
+ If a postmortem artifact already exists (regeneration), sets content to NULL so
+ the GET endpoint returns 202. Previous content is preserved in artifact_versions
+ from the original save. The save_postmortem tool will later fill in the content
+ via ON CONFLICT UPDATE.
"""
try:
with db_pool.get_admin_connection() as conn:
@@ -265,15 +265,29 @@ def _reserve_postmortem_row(user_id: str, incident_id: str, session_id: str) ->
set_rls_context(cur, conn, user_id, log_prefix="[PostmortemAction:reserve]")
+ # Derive the title from the incident's alert_title
cur.execute(
- """INSERT INTO postmortems (incident_id, user_id, org_id, content, generation_session_id)
- VALUES (%s, %s, %s, NULL, %s)
- ON CONFLICT (incident_id)
+ "SELECT alert_title FROM incidents WHERE id = %s",
+ (incident_id,),
+ )
+ incident_row = cur.fetchone()
+ alert_title = incident_row[0] if incident_row else None
+ short_id = str(incident_id)[:8]
+ title = f"Postmortem: {alert_title} [{short_id}]" if alert_title else f"Postmortem ({incident_id})"
+
+ cur.execute(
+ """INSERT INTO artifacts
+ (org_id, user_id, title, content, category,
+ incident_id, generation_session_id, last_edited_by, updated_at)
+ VALUES (%s, %s, %s, NULL, 'postmortem',
+ %s, %s, 'agent', CURRENT_TIMESTAMP)
+ ON CONFLICT (incident_id) WHERE incident_id IS NOT NULL
DO UPDATE SET content = NULL,
generation_session_id = EXCLUDED.generation_session_id,
+ last_edited_by = 'agent',
updated_at = CURRENT_TIMESTAMP""",
- (incident_id, user_id, org_id, session_id),
+ (org_id, user_id, title, incident_id, session_id),
)
conn.commit()
except Exception:
- logger.exception("[PostmortemAction] Failed to reserve postmortem row")
+ logger.exception("[PostmortemAction] Failed to reserve postmortem artifact row")
diff --git a/server/services/actions/system_actions.py b/server/services/actions/system_actions.py
index 5df59f6e4..6beb37c40 100644
--- a/server/services/actions/system_actions.py
+++ b/server/services/actions/system_actions.py
@@ -16,6 +16,32 @@
logger = logging.getLogger(__name__)
+DEFAULT_MEMORY_CONSOLIDATION_INSTRUCTIONS = """You are a memory maintenance agent. Review and consolidate the org memory bank.
+
+Use your memory tools (list_memories, read_memory, write_memory, edit_memory, append_to_memory, delete_memory) to:
+1. Review what exists (list_memories + read_memory on entries that look duplicated or stale)
+2. Merge duplicates (write the merged content to the better entry, delete the other)
+3. Rewrite entries that need cleanup (edit_memory for surgical fixes, write_memory with overwrite for full rewrites)
+4. Delete entries that are clearly stale or fully subsumed by another
+
+GOALS:
+- MERGE duplicates — if two entries cover the same topic, combine into one
+- REMOVE stale entries — facts clearly outdated or contradicted by newer entries
+- FIX formatting — ensure entries follow consistent structure
+- DEDUPLICATE within entries — remove repeated paragraphs within a single entry
+- CONVERT relative dates — "yesterday", "last week" → absolute dates where context allows
+
+RULES:
+- Be CONSERVATIVE — only act when confident the change improves things
+- NEVER delete entries with unique, non-redundant information
+- ALWAYS prefer merging over deleting
+- Preserve all factual content during merges — don't lose information
+- If unsure, leave the entry alone
+- Use the updated_at timestamps from list_memories to judge staleness — bias toward keeping recently modified entries over older conflicting ones
+- NEVER merge or delete postmortem entries — each one documents a unique incident. Only fix formatting within them.
+
+If the memory bank looks clean, just respond "DONE: no changes needed" without making any modifications."""
+
SYSTEM_ACTIONS = [
{
"system_key": "generate_postmortem",
@@ -36,6 +62,15 @@
"enabled": False,
"instructions": None,
},
+ {
+ "system_key": "memory_consolidation",
+ "name": "Memory Consolidation",
+ "description": "Nightly review of org memory bank: merges duplicates, removes stale entries, fixes formatting, and ensures the memory index stays lean and accurate.",
+ "trigger_type": "on_schedule",
+ "trigger_config": {"interval_seconds": 86400},
+ "mode": "agent",
+ "instructions": None,
+ },
]
@@ -45,6 +80,8 @@ def _get_default_instructions(system_key: str) -> str:
return DEFAULT_POSTMORTEM_INSTRUCTIONS
if system_key == "alert_gap_audit":
return DEFAULT_ALERT_GAP_INSTRUCTIONS
+ if system_key == "memory_consolidation":
+ return DEFAULT_MEMORY_CONSOLIDATION_INSTRUCTIONS
raise ValueError(f"Unknown system action: {system_key}")
diff --git a/server/services/artifacts/store.py b/server/services/artifacts/store.py
index bb57ebcc7..855aa2868 100644
--- a/server/services/artifacts/store.py
+++ b/server/services/artifacts/store.py
@@ -61,18 +61,18 @@ def upsert_artifact_by_title(
source: str,
session_id: Optional[str] = None,
) -> Tuple[str, int]:
- """Create or replace an artifact addressed by (org_id, title) and version it.
+ """Create or replace an artifact addressed by (org_id, category, title) and version it.
- Relies on the unique index idx_artifacts_org_title for an atomic upsert.
+ Relies on the unique index idx_artifacts_org_cat_title for an atomic upsert.
last_edited_by is derived from source: a 'manual' write came from a human
(the UI), anything else from the agent. Returns (artifact_id, version_number).
"""
last_edited_by = "user" if source == "manual" else "agent"
cursor.execute(
- """INSERT INTO artifacts (org_id, user_id, title, content, last_edited_by, updated_at)
- VALUES (%s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
- ON CONFLICT (org_id, title)
+ """INSERT INTO artifacts (org_id, user_id, title, content, category, last_edited_by, updated_at)
+ VALUES (%s, %s, %s, %s, 'artifact', %s, CURRENT_TIMESTAMP)
+ ON CONFLICT (org_id, category, title)
DO UPDATE SET content = EXCLUDED.content,
user_id = EXCLUDED.user_id,
last_edited_by = EXCLUDED.last_edited_by,
diff --git a/server/services/correlation/embedding_client.py b/server/services/correlation/embedding_client.py
index 1d7c2e616..ea5d48354 100644
--- a/server/services/correlation/embedding_client.py
+++ b/server/services/correlation/embedding_client.py
@@ -1,84 +1,159 @@
"""
-Embedding client for the t2v-transformers container.
+Provider-agnostic embedding client.
-Calls the Weaviate text2vec-transformers inference service to get
-dense vector embeddings for text.
+Parses the EMBEDDING_MODEL env var (format: "provider/model-name") and calls
+the appropriate embedding API. Falls back to None when unconfigured, allowing
+the caller to use a non-vector similarity method.
+
+Supported providers:
+ - openai/ → uses OPENAI_API_KEY
+ - google/ → uses GOOGLE_AI_API_KEY
+ - bedrock/ → uses BEDROCK_ACCESS_KEY_ID + BEDROCK_SECRET_ACCESS_KEY
"""
import logging
+import os
from functools import lru_cache
-from typing import List, Optional
-
-import requests
+from typing import List, Optional, Tuple
logger = logging.getLogger(__name__)
-T2V_URL = "http://t2v-transformers:8080"
-T2V_TIMEOUT = 5.0
+
+def _parse_embedding_model() -> Tuple[Optional[str], Optional[str]]:
+ """Parse EMBEDDING_MODEL env var into (provider, model) tuple.
+
+ Returns (None, None) if unset or invalid.
+ """
+ raw = os.getenv("EMBEDDING_MODEL", "").strip()
+ if not raw or "/" not in raw:
+ return None, None
+
+ provider, _, model = raw.partition("/")
+ provider = provider.lower().strip()
+ model = model.strip()
+
+ if not provider or not model:
+ return None, None
+
+ return provider, model
class EmbeddingClient:
- """Client for the t2v-transformers embedding service."""
+ """Provider-agnostic embedding client."""
- def __init__(self, base_url: str = T2V_URL, timeout: float = T2V_TIMEOUT):
- self.base_url = base_url.rstrip("/")
- self.timeout = timeout
- self._session: Optional[requests.Session] = None
+ def __init__(self):
+ self.provider, self.model = _parse_embedding_model()
+ self._initialized = False
+ self._client = None
@property
- def session(self) -> requests.Session:
- if self._session is None:
- self._session = requests.Session()
- return self._session
-
- def close(self) -> None:
- if self._session is not None:
- self._session.close()
- self._session = None
+ def is_configured(self) -> bool:
+ return self.provider is not None and self.model is not None
def embed(self, text: str) -> Optional[List[float]]:
"""Get embedding vector for a single text string.
- Args:
- text: The text to embed.
-
- Returns:
- List of floats representing the embedding, or None on failure.
+ Returns None if unconfigured or on failure.
"""
+ if not self.is_configured:
+ return None
+
if not text or not text.strip():
return None
try:
- response = self.session.post(
- f"{self.base_url}/vectors",
- json={"text": text},
- timeout=self.timeout,
- )
- response.raise_for_status()
- data = response.json()
- return data.get("vector")
- except requests.exceptions.Timeout:
- logger.warning(
- "[EmbeddingClient] Request timed out for text: %s...", text[:50]
- )
+ if self.provider == "openai":
+ return self._embed_openai(text)
+ elif self.provider == "google":
+ return self._embed_google(text)
+ elif self.provider == "bedrock":
+ return self._embed_bedrock(text)
+ else:
+ logger.warning(
+ "[EmbeddingClient] Unknown provider: %s", self.provider
+ )
+ return None
+ except Exception as e:
+ logger.warning("[EmbeddingClient] Embedding failed (%s): %s", self.provider, e)
return None
- except requests.exceptions.RequestException as e:
- logger.warning("[EmbeddingClient] Request failed: %s", e)
+
+ def _embed_openai(self, text: str) -> Optional[List[float]]:
+ """Call OpenAI embeddings API."""
+ from openai import OpenAI
+
+ api_key = os.getenv("OPENAI_API_KEY", "").strip()
+ if not api_key:
+ logger.warning("[EmbeddingClient] OPENAI_API_KEY not set")
return None
- except Exception as e:
- logger.warning("[EmbeddingClient] Unexpected error: %s", e)
+
+ if self._client is None:
+ self._client = OpenAI(api_key=api_key)
+
+ response = self._client.embeddings.create(
+ input=text,
+ model=self.model,
+ )
+ return response.data[0].embedding
+
+ def _embed_google(self, text: str) -> Optional[List[float]]:
+ """Call Google AI embeddings API."""
+ from google import genai
+
+ api_key = os.getenv("GOOGLE_AI_API_KEY", "").strip()
+ if not api_key:
+ logger.warning("[EmbeddingClient] GOOGLE_AI_API_KEY not set")
return None
- def embed_batch(self, texts: List[str]) -> List[Optional[List[float]]]:
- """Get embeddings for multiple texts.
+ if self._client is None:
+ self._client = genai.Client(api_key=api_key)
- Note: The t2v-transformers container doesn't support batch requests,
- so this calls embed() for each text sequentially.
- """
- return [self.embed(text) for text in texts]
+ response = self._client.models.embed_content(
+ model=self.model,
+ contents=text,
+ )
+ return response.embeddings[0].values
+
+ def _embed_bedrock(self, text: str) -> Optional[List[float]]:
+ """Call AWS Bedrock embeddings via boto3."""
+ import json
+ import boto3
+
+ access_key = os.getenv("BEDROCK_ACCESS_KEY_ID", "").strip()
+ secret_key = os.getenv("BEDROCK_SECRET_ACCESS_KEY", "").strip()
+ region = os.getenv("BEDROCK_REGION") or os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION") or "us-east-1"
+
+ if not access_key or not secret_key:
+ logger.warning("[EmbeddingClient] BEDROCK_ACCESS_KEY_ID/SECRET not set")
+ return None
+
+ if self._client is None:
+ self._client = boto3.client(
+ "bedrock-runtime",
+ region_name=region,
+ aws_access_key_id=access_key,
+ aws_secret_access_key=secret_key,
+ )
+
+ response = self._client.invoke_model(
+ modelId=self.model,
+ body=json.dumps({"inputText": text}),
+ contentType="application/json",
+ accept="application/json",
+ )
+ result = json.loads(response["body"].read())
+ return result.get("embedding")
@lru_cache(maxsize=1)
def get_embedding_client() -> EmbeddingClient:
"""Get or create the singleton embedding client."""
- return EmbeddingClient()
+ client = EmbeddingClient()
+ if client.is_configured:
+ logger.info(
+ "[EmbeddingClient] Configured with provider=%s, model=%s",
+ client.provider,
+ client.model,
+ )
+ else:
+ logger.info("[EmbeddingClient] No EMBEDDING_MODEL set — using Jaccard fallback")
+ return client
diff --git a/server/services/correlation/strategies/similarity.py b/server/services/correlation/strategies/similarity.py
index 6ba8a0e8e..b5e161f53 100644
--- a/server/services/correlation/strategies/similarity.py
+++ b/server/services/correlation/strategies/similarity.py
@@ -2,8 +2,8 @@
Text-similarity correlation strategy.
Scores an alert against an incident using cosine similarity on
-dense vector embeddings from the t2v-transformers service, with
-Jaccard fallback when embeddings are unavailable.
+dense vector embeddings (when EMBEDDING_MODEL is configured), with
+Jaccard token-similarity fallback when embeddings are unavailable.
"""
import logging
@@ -21,7 +21,7 @@
class SimilarityStrategy(CorrelationStrategy):
- """Vector-based similarity scoring for titles, with service-name overlap."""
+ """Vector-based similarity scoring for titles, with Jaccard fallback."""
TITLE_WEIGHT = 0.7
SERVICE_WEIGHT = 0.3
@@ -33,10 +33,10 @@ def score(
incident_title: str,
incident_services: List[str],
) -> float:
- """Score based on semantic similarity between alert and incident.
+ """Score based on similarity between alert and incident.
- Uses cosine similarity on embeddings from t2v-transformers, falling
- back to Jaccard token similarity if embeddings unavailable.
+ Uses cosine similarity on embeddings when EMBEDDING_MODEL is configured,
+ falling back to Jaccard token similarity otherwise.
Args:
alert_title: Title / summary of the alert.
@@ -56,6 +56,7 @@ def score(
return 0.0
title_sim = self._vector_similarity(alert_title, incident_title)
+ # Embedding unavailable or unconfigured — fall back to token overlap
if title_sim is None:
title_sim = self._jaccard_similarity(alert_title, incident_title)
@@ -66,10 +67,13 @@ def score(
def _vector_similarity(self, text_a: str, text_b: str) -> Optional[float]:
"""Compute cosine similarity using embeddings.
- Returns None if embeddings couldn't be retrieved.
+ Returns None if embeddings aren't configured or couldn't be retrieved.
"""
+ client = get_embedding_client()
+ if not client.is_configured:
+ return None
+
try:
- client = get_embedding_client()
vec_a = client.embed(text_a)
vec_b = client.embed(text_b)
@@ -98,7 +102,7 @@ def _cosine_similarity(vec_a: List[float], vec_b: List[float]) -> float:
return max(0.0, min(1.0, similarity))
def _jaccard_similarity(self, text_a: str, text_b: str) -> float:
- """Fallback: Jaccard token similarity."""
+ """Jaccard token similarity between two texts."""
tokens_a = self._tokenize(text_a)
tokens_b = self._tokenize(text_b)
return self._jaccard(tokens_a, tokens_b)
@@ -132,6 +136,7 @@ def _service_similarity(alert_service: str, incident_services: List[str]) -> flo
)
return 0.0
+ # Exact match — no fuzzy comparison needed
if alert_service in incident_services:
return 1.0
diff --git a/server/services/memory/__init__.py b/server/services/memory/__init__.py
new file mode 100644
index 000000000..dbd8c7e9e
--- /dev/null
+++ b/server/services/memory/__init__.py
@@ -0,0 +1,7 @@
+MEMORY_CATEGORIES = ("context", "runbook", "infrastructure", "learned", "postmortem")
+
+# Agent-only categories — not exposed via user-facing memory routes
+AGENT_CATEGORIES = ("artifact",)
+
+# All valid categories (used by memory_tool for validation)
+ALL_CATEGORIES = MEMORY_CATEGORIES + AGENT_CATEGORIES
diff --git a/server/services/memory/collector.py b/server/services/memory/collector.py
new file mode 100644
index 000000000..4d09c61f0
--- /dev/null
+++ b/server/services/memory/collector.py
@@ -0,0 +1,170 @@
+"""
+Memory Extraction Agent — Background Celery Task
+
+Triggered after each turn (interactive or RCA) to analyze the conversation
+and extract durable learnings into org memory. Modeled after Claude Code's
+extraction agent:
+
+- Fires post-turn (non-blocking, via Celery)
+- Full agentic loop: the LLM can browse, read, write, and append memories
+- Dedup is natural: the agent reads existing content before deciding to act
+- DB-level safety: FOR UPDATE on appends, ON CONFLICT on writes
+- Limited turn budget with efficiency instructions
+
+Key differences from Claude Code:
+- Uses Celery instead of forked process (no prompt cache sharing)
+- DB-backed memory instead of filesystem
+- RLS context required for all DB operations
+"""
+
+import logging
+import time
+from typing import List
+
+from langchain_core.messages import HumanMessage
+from langchain.agents import create_agent
+
+from celery_config import celery_app
+from chat.backend.agent.llm import ModelConfig
+from chat.backend.agent.providers import create_chat_model
+from chat.backend.agent.tools.memory_tool import build_memory_tools
+from chat.backend.agent.utils.llm_context_manager import LLMContextManager
+from chat.backend.agent.utils.chat_context_manager import ChatContextManager
+from services.memory.index_builder import build_memory_index
+from utils.auth.stateless_auth import get_org_id_for_user
+from utils.hooks import get_hook
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+MAX_AGENT_TURNS = 30
+
+# ---------------------------------------------------------------------------
+# Agent system prompt
+# ---------------------------------------------------------------------------
+
+_AGENT_SYSTEM = """You are a memory extraction agent. You run in the background after conversations to save durable organizational knowledge.
+
+You have tools to browse and manage org memory. Your job:
+1. Analyze the conversation for facts worth remembering long-term
+2. Use list_memories and read_memory to check what already exists
+3. Write new entries or append to existing ones — only if genuinely new information
+
+WHAT TO SAVE (extract ANY of these if present — even if the conversation seems trivial):
+- Facts the USER states about their organization (team structure, escalation policies, people/roles, preferences)
+- Infrastructure details mentioned by the user or discovered during investigation
+- Resolution steps and root causes from incidents/debugging
+- Procedures, runbooks, or standard operating procedures mentioned
+- Upcoming events, releases, deadlines, or planned changes the user mentions (HIGH PRIORITY — always save these)
+- Decisions made during the conversation (e.g. "we'll merge X", "we're switching to Y")
+- Any concrete fact you'd want to know if you were talking to this user next week
+
+IMPORTANT: Err on the side of SAVING. If the user mentions ANY concrete fact about their work (a release date, a team member, a tool they use, a decision they made), SAVE IT. The cost of saving something unnecessary is low. The cost of forgetting something useful is high.
+
+CATEGORIES:
+- context: Team preferences, escalation paths, org policies, people & roles, behavioral instructions, upcoming events/releases
+- infrastructure: Service topology, deployment chains, monitoring configs, dependencies
+- runbook: Step-by-step procedures for known issues
+- learned: Non-obvious root causes, debugging patterns, resolution steps
+- postmortem: Incident postmortem summaries and key takeaways
+
+RULES:
+1. Only save facts useful in FUTURE conversations
+2. DO NOT save debugging artifacts (specific log lines, one-off metric values, transient error messages)
+3. DO NOT save obvious/generic knowledge the assistant generated (only save facts the USER provided)
+4. Create a NEW entry when the fact is a distinct topic (e.g. a release schedule, a new team member). Append to an existing entry only when the new fact genuinely extends that entry's topic.
+5. Use clear, specific titles that make entries easy to find (e.g. "Release Schedule" not "Misc Notes")
+6. When appending, update the description to reflect the new content so the memory index stays accurate
+7. PAY SPECIAL ATTENTION to facts the user casually mentions (e.g. "our VP is X", "we have a release Friday", "we use Y for Z") — these are often the most valuable even if the conversation itself is trivial
+
+You MUST save at least one fact if the user mentioned anything concrete about their organization, timeline, or decisions. Only respond "DONE: nothing to extract" if the conversation is purely generic Q&A with zero org-specific information."""
+
+
+# ---------------------------------------------------------------------------
+# Celery Task
+# ---------------------------------------------------------------------------
+
+
+@celery_app.task(
+ name="services.memory.collector.extract_memories_from_session",
+ bind=True,
+ soft_time_limit=90,
+ time_limit=120,
+ max_retries=0,
+ acks_late=True,
+)
+def extract_memories_from_session(
+ self,
+ session_id: str,
+ user_id: str,
+):
+ """Extract and persist learnings from a conversation turn. Should be enqueued after each meaningful turn completes."""
+ log_prefix = f"[MemoryCollector:{session_id[:8]}]"
+
+ try:
+ # Check before_llm_call hook (billing/entitlement gate)
+ hook_allowed, hook_message = get_hook("before_llm_call")(get_org_id_for_user(user_id), user_id)
+ if not hook_allowed:
+ logger.info("%s Hook blocked: %s", log_prefix, hook_message)
+ return {"status": "hook_blocked"}
+
+ # Load conversation
+ messages = LLMContextManager.load_context_history(session_id, user_id)
+ if not messages:
+ logger.debug("%s No messages to process", log_prefix)
+ return {"status": "skipped", "reason": "no_messages"}
+
+ # Build memory index upfront so the agent has a map without wasting a turn
+ memory_index = build_memory_index(user_id) or "(no existing memories)"
+
+ # Run the extraction agent
+ start_time = time.time()
+ _run_extraction_agent(messages, memory_index, user_id, session_id, log_prefix)
+ elapsed_ms = int((time.time() - start_time) * 1000)
+
+ logger.info("%s Extraction completed in %dms", log_prefix, elapsed_ms)
+ return {"status": "ok", "elapsed_ms": elapsed_ms}
+
+ except Exception as e:
+ logger.exception("%s Extraction failed", log_prefix)
+ return {"status": "error", "error": str(e)}
+
+
+# ---------------------------------------------------------------------------
+# Agentic loop
+# ---------------------------------------------------------------------------
+
+
+def _run_extraction_agent(
+ conversation_messages: List,
+ memory_index: str,
+ user_id: str,
+ session_id: str,
+ log_prefix: str,
+):
+ """Multi-turn agent loop: browses existing memories, writes new ones."""
+ tools = build_memory_tools(user_id, session_id=session_id)
+
+ llm = create_chat_model(ModelConfig.MAIN_MODEL, temperature=0.0, streaming=False)
+
+ agent = create_agent(
+ model=llm,
+ tools=tools,
+ system_prompt=_AGENT_SYSTEM,
+ )
+
+ # Format the conversation for context
+ conversation_text = ChatContextManager._format_messages_for_summary(conversation_messages)
+ if len(conversation_text) > 20000:
+ conversation_text = conversation_text[-20000:]
+
+ user_content = f"EXISTING MEMORY INDEX:\n{memory_index}\n\nCONVERSATION TO ANALYZE:\n\n{conversation_text}"
+
+ agent.invoke(
+ {"messages": [HumanMessage(content=user_content)]},
+ config={"recursion_limit": MAX_AGENT_TURNS * 2 + 2},
+ )
+
diff --git a/server/services/memory/index_builder.py b/server/services/memory/index_builder.py
new file mode 100644
index 000000000..64c371953
--- /dev/null
+++ b/server/services/memory/index_builder.py
@@ -0,0 +1,77 @@
+"""
+Memory Index Builder
+
+Generates the memory index for injection into the agent's system prompt.
+Lists all memory entries with title + description so the agent knows what
+org knowledge exists and can call read_memory() to load specific topics.
+"""
+
+import logging
+
+from services.memory.queries import get_memory_entries
+
+logger = logging.getLogger(__name__)
+
+MAX_INDEX_LINES = 200
+MAX_INDEX_BYTES = 25000
+
+
+# ---------------------------------------------------------------------------
+# Index formatting (used by composer.py for the static TOC)
+# ---------------------------------------------------------------------------
+
+
+def build_memory_index(user_id: str) -> str:
+ """Build a table-of-contents index of all org memory entries.
+
+ Returns a formatted string listing every entry by category with its
+ description. The agent uses this to discover what knowledge exists and
+ calls read_memory() to load full content on demand.
+ """
+ entries = get_memory_entries(user_id)
+ if not entries:
+ return ""
+
+ # Regroup by category (entries come sorted by updated_at, need category grouping)
+ from itertools import groupby
+ sorted_entries = sorted(entries, key=lambda e: e["category"])
+
+ lines = ["ORG MEMORY INDEX — use read_memory(category, title) for full content:\n"]
+
+ def _prompt_label(value: object, max_chars: int = 500) -> str:
+ return " ".join(str(value or "").split())[:max_chars]
+
+ for category, group in groupby(sorted_entries, key=lambda e: e["category"]):
+ group_list = list(group)
+ lines.append(f"## {category} ({len(group_list)})")
+
+ for entry in group_list:
+ safe_title = _prompt_label(entry["title"])
+ safe_desc = _prompt_label(entry["description"])
+ desc_suffix = f" # {safe_desc}" if safe_desc else ""
+ lines.append(f"- {category}/{safe_title}{desc_suffix}")
+
+ result = "\n".join(lines)
+
+ # Enforce line budget
+ truncated = False
+ result_lines = result.split("\n")
+ if len(result_lines) > MAX_INDEX_LINES:
+ result = "\n".join(result_lines[:MAX_INDEX_LINES])
+ result += "\n... (index truncated — use list_memories() to see all)"
+ truncated = True
+
+ # Enforce byte budget
+ if len(result.encode("utf-8")) > MAX_INDEX_BYTES:
+ while len(result.encode("utf-8")) > MAX_INDEX_BYTES and "\n" in result:
+ result = result.rsplit("\n", 1)[0]
+ result += "\n... (index truncated)"
+ truncated = True
+
+ if truncated:
+ logger.warning("[MemoryIndex] Index truncated: %d entries", len(entries))
+
+ # LangChain interprets {text} as template variables — escape them
+ result = result.replace("{", "{{").replace("}", "}}")
+
+ return result
diff --git a/server/services/memory/injector.py b/server/services/memory/injector.py
new file mode 100644
index 000000000..871577f4c
--- /dev/null
+++ b/server/services/memory/injector.py
@@ -0,0 +1,278 @@
+"""
+Memory Injector — Async Prefetch + Single-Pass LLM Selection
+
+Runs as a non-blocking prefetch in parallel with prompt building.
+One fast LLM call selects up to 5 relevant memories from the index
+(title + description). Whatever it selects gets injected into the system prompt.
+- Hard caps: 5 entries max, 4KB per entry.
+- Dedup: never re-surface a memory already shown this session.
+"""
+
+import asyncio
+import json
+import logging
+import time
+from datetime import datetime, timezone, timedelta
+from typing import Dict, List, Optional, Set, Tuple
+
+from utils.cache.redis_client import get_redis_client
+from services.memory.queries import get_memory_entries, fetch_memory_content
+from chat.backend.agent.providers import create_chat_model
+from langchain_core.messages import SystemMessage, HumanMessage
+from chat.backend.agent.llm import ModelConfig
+
+
+logger = logging.getLogger(__name__)
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+MAX_SELECTED = 5
+MAX_MEMORY_BYTES_PER_ENTRY = 4096
+STALENESS_DAYS = 2
+
+SELECTOR_MODEL = ModelConfig.RCA_MODEL
+SELECTOR_TIMEOUT = 5.0
+
+_SURFACED_SET_TTL = 86400
+
+# ---------------------------------------------------------------------------
+# Selector prompt — strict, single-pass
+# ---------------------------------------------------------------------------
+
+_SELECTOR_SYSTEM = """You are selecting memories that will be useful to Aurora (a cloud operations AI) as it processes a user's query. Aurora investigates incidents, runs root cause analysis, manages infrastructure, and assists with DevOps/SRE tasks. You will be given the user's message and a list of available memory entries with their category, title, and description.
+
+Return a list of entries that will clearly be useful to Aurora as it processes this query (up to 5). Only include memories that you are certain will be helpful based on their title and description.
+- If you are unsure if a memory will be useful, do not include it. Be selective and discerning.
+- If there are no memories that would clearly be useful, return an empty list.
+- Do not select memories that merely repeat generic knowledge Aurora already has. DO still select memories containing org-specific context, warnings, known issues, past incident patterns, or team preferences — those always matter.
+- Prioritize: runbooks when troubleshooting, infrastructure context for system questions, learned patterns from past incidents for debugging, org preferences for behavioral questions.
+
+Return JSON:
+{"selected": [{"category": "...", "title": "..."}, ...]}
+
+If nothing is relevant: {"selected": []}"""
+
+_SELECTOR_USER = """USER MESSAGE:
+{user_message}
+
+MEMORY INDEX:
+{manifest}
+
+Select 0-5 memories. Only include those you are certain will help. JSON only."""
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+
+class MemoryPrefetch:
+ """Handle for an in-flight memory prefetch operation."""
+
+ def __init__(self, user_id: str, session_id: str, user_message: str):
+ self.user_id = user_id
+ self.session_id = session_id
+ self.user_message = user_message
+ self._task: Optional[asyncio.Task] = None
+ self._result: Optional[str] = None
+ self._started_at: float = 0
+
+ def start(self):
+ """Launch the prefetch as an asyncio task (must be called from async context)."""
+ self._started_at = time.perf_counter()
+ self._task = asyncio.create_task(self._execute())
+
+ async def get_result_async(self, timeout: float = SELECTOR_TIMEOUT + 2.0) -> str:
+ """Await prefetch result without blocking the event loop."""
+ if self._result is not None:
+ return self._result
+
+ if self._task is None:
+ return ""
+
+ try:
+ self._result = await asyncio.wait_for(self._task, timeout=timeout)
+ except asyncio.TimeoutError:
+ elapsed = (time.perf_counter() - self._started_at) * 1000
+ logger.warning("[MemoryInjector] Prefetch timed out after %.0fms", elapsed)
+ self._task.cancel()
+ self._result = ""
+ except Exception as e:
+ elapsed = (time.perf_counter() - self._started_at) * 1000
+ logger.warning("[MemoryInjector] Prefetch failed after %.0fms: %s", elapsed, e)
+ self._result = ""
+
+ return self._result
+
+ async def _execute(self) -> str:
+ try:
+ already_surfaced = await asyncio.to_thread(_get_surfaced_set, self.session_id)
+
+ entries = await asyncio.to_thread(get_memory_entries, self.user_id)
+ if not entries:
+ return ""
+
+ # Remove already-surfaced entries (don't waste selection slots)
+ available = [e for e in entries if _entry_key(e) not in already_surfaced]
+ if not available:
+ return ""
+
+ # Single async LLM pass: select up to 5
+ selected = await _select_relevant_memories_async(self.user_message, available)
+ if not selected:
+ return ""
+
+ # Fetch content of the selected entries
+ memories = await asyncio.to_thread(fetch_memory_content, self.user_id, selected)
+ if not memories:
+ return ""
+
+ # Format and inject (with staleness headers + per-entry cap)
+ injection_text, surfaced_keys = _format_for_injection(memories)
+ if not injection_text:
+ return ""
+
+ await asyncio.to_thread(_mark_surfaced, self.session_id, surfaced_keys)
+
+ logger.info(
+ "[MemoryInjector] Injected %d memories for session %s",
+ len(surfaced_keys), self.session_id,
+ )
+ return injection_text
+
+ except Exception:
+ logger.exception("[MemoryInjector] Unhandled error in prefetch")
+ return ""
+
+
+# ---------------------------------------------------------------------------
+# Select (single async LLM call)
+# ---------------------------------------------------------------------------
+
+
+async def _select_relevant_memories_async(user_message: str, entries: List[Dict]) -> List[Dict]:
+ """One strict async LLM call: select up to 5 from the index."""
+
+ manifest_lines = []
+ for entry in entries:
+ ts = entry["updated_at"].isoformat() if entry["updated_at"] else "unknown"
+ desc = entry["description"][:150] if entry["description"] else "(no description)"
+ manifest_lines.append(f"- [{entry['category']}] \"{entry['title']}\" | updated: {ts} | {desc}")
+
+ prompt = _SELECTOR_USER.format(
+ user_message=user_message[:2000],
+ manifest="\n".join(manifest_lines),
+ )
+
+ try:
+ llm = create_chat_model(SELECTOR_MODEL, temperature=0.0, streaming=False, max_tokens=256)
+ response = await llm.ainvoke(
+ [
+ SystemMessage(content=_SELECTOR_SYSTEM),
+ HumanMessage(content=prompt),
+ ],
+ config={"run_name": "memory_selector"},
+ )
+
+ content = response.content if hasattr(response, "content") else str(response)
+ json_str = content.strip()
+ if json_str.startswith("```"):
+ json_str = json_str.split("\n", 1)[1] if "\n" in json_str else json_str
+ json_str = json_str.rsplit("```", 1)[0]
+
+ parsed = json.loads(json_str)
+ selected_raw = parsed.get("selected", [])
+ if not isinstance(selected_raw, list):
+ return []
+
+ # Validate against actual entries
+ entry_lookup = {_entry_key(e): e for e in entries}
+ validated = []
+ for sel in selected_raw[:MAX_SELECTED]:
+ title = sel.get("title", "").strip().strip('"')
+ key = f"{sel.get('category', '')}/{title}"
+ if key in entry_lookup:
+ validated.append(entry_lookup[key])
+
+ logger.info("[MemoryInjector] Selected %d/%d entries", len(validated), len(entries))
+ return validated
+
+ except json.JSONDecodeError as e:
+ logger.warning("[MemoryInjector] Selector returned invalid JSON: %s", e)
+ return []
+ except Exception as e:
+ logger.warning("[MemoryInjector] Selector failed: %s", e)
+ return []
+
+
+# ---------------------------------------------------------------------------
+# Format for injection
+# ---------------------------------------------------------------------------
+
+
+def _format_for_injection(memories: List[Dict]) -> Tuple[str, Set[str]]:
+ """Format memories with staleness headers and per-entry cap."""
+ now = datetime.now(timezone.utc).replace(tzinfo=None)
+ parts = []
+ surfaced: Set[str] = set()
+
+ parts.append("RELEVANT MEMORIES (pre-loaded — only call read_memory() if content below is marked truncated):\n")
+
+ for mem in memories:
+ key = _entry_key(mem)
+ content = mem["content"]
+
+ # Per-entry cap: 4KB
+ if len(content.encode("utf-8")) > MAX_MEMORY_BYTES_PER_ENTRY:
+ content = content[:MAX_MEMORY_BYTES_PER_ENTRY]
+ content += "\n... (truncated — use read_memory() for full content)"
+
+ # Staleness header
+ staleness = ""
+ if mem["updated_at"]:
+ age = now - mem["updated_at"]
+ if age > timedelta(days=STALENESS_DAYS):
+ staleness = f" ⚠️ Last updated {age.days}d ago — verify before acting"
+
+ parts.append(f"\n--- {mem['category']}/{mem['title']}{staleness} ---\n{content}\n")
+ surfaced.add(key)
+
+ if not surfaced:
+ return "", set()
+
+ result = "".join(parts)
+ result = result.replace("{", "{{").replace("}", "}}")
+ return result, surfaced
+
+
+# ---------------------------------------------------------------------------
+# Session dedup (Redis)
+# ---------------------------------------------------------------------------
+
+
+def _entry_key(entry: Dict) -> str:
+ return f"{entry.get('category', '')}/{entry.get('title', '')}"
+
+
+def _get_surfaced_set(session_id: str) -> Set[str]:
+ try:
+ r = get_redis_client()
+ members = r.smembers(f"mem:inject:surfaced:{session_id}")
+ return {m.decode("utf-8") if isinstance(m, bytes) else m for m in members} if members else set()
+ except Exception:
+ return set()
+
+
+def _mark_surfaced(session_id: str, keys: Set[str]):
+ if not keys:
+ return
+ try:
+ r = get_redis_client()
+ pipe = r.pipeline()
+ pipe.sadd(f"mem:inject:surfaced:{session_id}", *keys)
+ pipe.expire(f"mem:inject:surfaced:{session_id}", _SURFACED_SET_TTL)
+ pipe.execute()
+ except Exception:
+ logger.warning("Failed to mark surfaced memory keys for session %s", session_id, exc_info=True)
diff --git a/server/services/memory/migration_task.py b/server/services/memory/migration_task.py
new file mode 100644
index 000000000..36000b809
--- /dev/null
+++ b/server/services/memory/migration_task.py
@@ -0,0 +1,199 @@
+"""
+Memory Data Migration Task
+
+One-time Celery task to migrate existing data into the new memory system:
+1. knowledge_base_memory → context memory entry
+2. infrastructure_context → infrastructure memory entry
+3. knowledge_base_documents (with content in S3) → runbook memory entries
+"""
+
+import io
+import logging
+
+from celery_config import celery_app
+from pypdf import PdfReader
+from utils.db.connection_pool import db_pool
+from utils.storage.storage import get_storage_manager
+from services.artifacts.store import create_version
+from utils.validation import strip_nul
+
+logger = logging.getLogger(__name__)
+
+def _normalize_storage_path(storage_path: str) -> str:
+ """Strip s3://bucket/ prefix from legacy KB document paths."""
+ if storage_path.startswith("s3://"):
+ parts = storage_path[5:].split("/", 1)
+ if len(parts) > 1:
+ return parts[1]
+ return storage_path
+
+
+def _resolve_user_for_org(cursor, org_id: str) -> str | None:
+ """Pick any user in the org so RLS context can be set during migration."""
+ cursor.execute(
+ "SELECT id FROM users WHERE org_id = %s ORDER BY id LIMIT 1",
+ (org_id,),
+ )
+ row = cursor.fetchone()
+ return row[0] if row else None
+
+
+def _set_rls_for_row(cursor, org_id: str, user_id: str) -> None:
+ """Set RLS session vars without committing (set_rls_context commits and breaks savepoints)."""
+ cursor.execute("SET LOCAL myapp.current_user_id = %s;", (user_id,))
+ cursor.execute("SET LOCAL myapp.current_org_id = %s;", (org_id,))
+
+
+@celery_app.task(name="migrate_kb_to_memory", bind=True, max_retries=3, default_retry_delay=60)
+def migrate_kb_to_memory(self):
+ """Migrate all existing KB data into memory artifacts."""
+ stats = {"context": 0, "infrastructure": 0, "documents": 0, "errors": 0}
+
+ try:
+ with db_pool.get_admin_connection() as conn:
+ with conn.cursor() as cursor:
+ # 1) Migrate knowledge_base_memory → context
+ cursor.execute(
+ "SELECT org_id, user_id, content FROM knowledge_base_memory WHERE content IS NOT NULL AND content != ''"
+ )
+ kb_rows = cursor.fetchall()
+
+ for org_id, user_id, content in kb_rows:
+ cursor.execute("SAVEPOINT migrate_kb_row")
+ try:
+ content = strip_nul(content)
+ _set_rls_for_row(cursor, org_id, user_id)
+ cursor.execute(
+ """INSERT INTO artifacts
+ (org_id, user_id, title, content, category, description,
+ last_edited_by, updated_at)
+ VALUES (%s, %s, 'Org Context', %s, 'context',
+ 'User-provided org context (migrated from knowledge base)', 'user', CURRENT_TIMESTAMP)
+ ON CONFLICT (org_id, category, title) DO NOTHING
+ RETURNING id""",
+ (org_id, user_id, content),
+ )
+ row = cursor.fetchone()
+ if row:
+ create_version(cursor, str(row[0]), org_id, user_id, content, source="migration")
+ stats["context"] += 1
+ cursor.execute("RELEASE SAVEPOINT migrate_kb_row")
+ except Exception as e:
+ logger.warning("[Migration] Error migrating KB memory for org %s: %s", org_id, e)
+ stats["errors"] += 1
+ cursor.execute("ROLLBACK TO SAVEPOINT migrate_kb_row")
+
+ # 2) Migrate infrastructure_context → infrastructure
+ cursor.execute(
+ "SELECT org_id, content FROM infrastructure_context WHERE content IS NOT NULL AND content != ''"
+ )
+ infra_rows = cursor.fetchall()
+
+ for org_id, content in infra_rows:
+ user_id = _resolve_user_for_org(cursor, org_id)
+ if not user_id:
+ logger.warning("[Migration] No user found for org %s, skipping infra context", org_id)
+ stats["errors"] += 1
+ continue
+
+ cursor.execute("SAVEPOINT migrate_infra_row")
+ try:
+ content = strip_nul(content)
+ _set_rls_for_row(cursor, org_id, user_id)
+ cursor.execute(
+ """INSERT INTO artifacts
+ (org_id, user_id, title, content, category, description,
+ last_edited_by, updated_at)
+ VALUES (%s, %s, 'Infrastructure Context', %s, 'infrastructure',
+ 'Auto-discovered infrastructure topology (migrated)', 'system', CURRENT_TIMESTAMP)
+ ON CONFLICT (org_id, category, title) DO NOTHING
+ RETURNING id""",
+ (org_id, user_id, content),
+ )
+ row = cursor.fetchone()
+ if row:
+ create_version(cursor, str(row[0]), org_id, user_id, content, source="migration")
+ stats["infrastructure"] += 1
+ cursor.execute("RELEASE SAVEPOINT migrate_infra_row")
+ except Exception as e:
+ logger.warning("[Migration] Error migrating infra context for org %s: %s", org_id, e)
+ stats["errors"] += 1
+ cursor.execute("ROLLBACK TO SAVEPOINT migrate_infra_row")
+
+ # 3) Migrate knowledge_base_documents (only if we can get content from S3)
+ cursor.execute(
+ """SELECT org_id, user_id, original_filename, storage_path
+ FROM knowledge_base_documents
+ WHERE status IN ('processed', 'ready')
+ AND storage_path IS NOT NULL"""
+ )
+ doc_rows = cursor.fetchall()
+
+ for org_id, user_id, filename, storage_path in doc_rows:
+ cursor.execute("SAVEPOINT migrate_doc_row")
+ try:
+ storage = get_storage_manager()
+ raw_bytes = storage.download_bytes(_normalize_storage_path(storage_path))
+
+ if not raw_bytes:
+ cursor.execute("RELEASE SAVEPOINT migrate_doc_row")
+ continue
+
+ ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
+
+ if ext == "pdf":
+ reader = PdfReader(io.BytesIO(raw_bytes))
+ content = "\n\n".join(
+ f"[Page {i+1}]\n{p.extract_text()}"
+ for i, p in enumerate(reader.pages)
+ if p.extract_text()
+ )
+ else:
+ content = raw_bytes.decode("utf-8", errors="replace")
+
+ content = strip_nul(content)
+
+ if not content.strip():
+ cursor.execute("RELEASE SAVEPOINT migrate_doc_row")
+ continue
+
+ _set_rls_for_row(cursor, org_id, user_id)
+
+ title = filename.rsplit(".", 1)[0] if "." in filename else filename
+ desc = f"Migrated from uploaded document: {filename}"
+
+ cursor.execute(
+ """INSERT INTO artifacts
+ (org_id, user_id, title, content, category, description,
+ last_edited_by, updated_at)
+ VALUES (%s, %s, %s, %s, 'runbook',
+ %s, 'user', CURRENT_TIMESTAMP)
+ ON CONFLICT (org_id, category, title) DO NOTHING
+ RETURNING id""",
+ (org_id, user_id, title, content, desc),
+ )
+ row = cursor.fetchone()
+ if row:
+ create_version(cursor, str(row[0]), org_id, user_id, content, source="migration")
+ stats["documents"] += 1
+
+ cursor.execute("RELEASE SAVEPOINT migrate_doc_row")
+ except Exception as e:
+ logger.warning("[Migration] Error migrating doc %s for org %s: %s", filename, org_id, e)
+ stats["errors"] += 1
+ cursor.execute("ROLLBACK TO SAVEPOINT migrate_doc_row")
+
+ conn.commit()
+
+ except Exception as exc:
+ logger.exception("[Migration] Fatal error — retrying (%d/%d)", self.request.retries, self.max_retries)
+ raise self.retry(exc=exc)
+
+ # Don't retry for row-level errors — those are permanently bad (NUL, corrupt PDF, etc.)
+ # and will fail identically every time. Only log them.
+ if stats["errors"] > 0:
+ logger.warning("[Migration] Completed with %d permanently-failed rows (not retrying): %s",
+ stats["errors"], stats)
+
+ logger.info("[Migration] Complete: %s", stats)
+ return stats
diff --git a/server/services/memory/queries.py b/server/services/memory/queries.py
new file mode 100644
index 000000000..9bec62fa6
--- /dev/null
+++ b/server/services/memory/queries.py
@@ -0,0 +1,78 @@
+"""
+Memory data-access helpers.
+
+Shared DB queries for fetching memory entries (artifacts table).
+Used by both the index builder and the injector.
+"""
+
+import logging
+from typing import Dict, List
+
+from utils.db.connection_pool import db_pool
+from utils.auth.stateless_auth import set_rls_context
+
+from services.memory import MEMORY_CATEGORIES
+
+logger = logging.getLogger(__name__)
+
+
+def get_memory_entries(user_id: str, limit: int = 200) -> List[Dict]:
+ """Fetch memory entry metadata (no content) for the user's org.
+
+ Returns a list of dicts with: category, title, description, updated_at.
+ Ordered by most recently updated first.
+ """
+ try:
+ with db_pool.get_admin_connection() as conn:
+ with conn.cursor() as cursor:
+ org_id = set_rls_context(cursor, conn, user_id, log_prefix="[MemoryIndex]")
+ if not org_id:
+ return []
+
+ cursor.execute(
+ """SELECT category, title, description, updated_at
+ FROM artifacts
+ WHERE org_id = %s AND category = ANY(%s)
+ ORDER BY updated_at DESC
+ LIMIT %s""",
+ (org_id, list(MEMORY_CATEGORIES), limit),
+ )
+ return [
+ {"category": r[0], "title": r[1], "description": r[2] or "", "updated_at": r[3]}
+ for r in cursor.fetchall()
+ ]
+ except Exception as e:
+ logger.warning("[MemoryQueries] Failed to fetch entries for user %s: %s", user_id, e)
+ return []
+
+
+def fetch_memory_content(user_id: str, entries: List[Dict]) -> List[Dict]:
+ """Fetch full content for a list of memory entries.
+
+ Takes entries (with category + title) and returns them enriched with content.
+ """
+ if not entries:
+ return []
+
+ try:
+ with db_pool.get_admin_connection() as conn:
+ with conn.cursor() as cursor:
+ org_id = set_rls_context(cursor, conn, user_id, log_prefix="[MemoryQueries:fetch]")
+ if not org_id:
+ return []
+
+ # Batch fetch all entries in a single query
+ pairs = [(entry["category"], entry["title"]) for entry in entries]
+ cursor.execute(
+ """SELECT category, title, content, updated_at FROM artifacts
+ WHERE org_id = %s AND (category, title) IN %s""",
+ (org_id, tuple(pairs)),
+ )
+ rows = cursor.fetchall()
+ return [
+ {"category": row[0], "title": row[1], "content": row[2] or "", "updated_at": row[3]}
+ for row in rows
+ ]
+ except Exception:
+ logger.exception("[MemoryQueries] Failed to fetch memory content")
+ return []
diff --git a/server/tests/architectural/test_rls_coverage.py b/server/tests/architectural/test_rls_coverage.py
index 3cc7b2941..4b9e3985a 100644
--- a/server/tests/architectural/test_rls_coverage.py
+++ b/server/tests/architectural/test_rls_coverage.py
@@ -17,8 +17,8 @@
"users", # queried during login before org context is set
"audit_log", # written with explicit org_id outside RLS session scope
"org_invitations", # queried during invite/join flows before org context
- "knowledge_base_documents", # Celery cleanup_stale_documents runs cross-org sweeps
- "knowledge_base_memory", # same Celery task dependency as knowledge_base_documents
+ "knowledge_base_documents", # Legacy KB tables kept as migration source data
+ "knowledge_base_memory", # Legacy KB tables kept as migration source data
"infrastructure_context", # org_id is the PK; single row per org, queried directly
"user_github_installations", # join table — installations are system-wide and shared
# across users; webhook handlers and the install callback
diff --git a/server/tests/auth/conftest.py b/server/tests/auth/conftest.py
index 56b8608fd..7b1247be1 100644
--- a/server/tests/auth/conftest.py
+++ b/server/tests/auth/conftest.py
@@ -40,7 +40,7 @@ def app():
del sys.modules[_mod]
for heavy in (
- "celery_config", "celery", "weaviate", "openai", "anthropic",
+ "celery_config", "celery", "openai", "anthropic",
"chat.background.task", "chat.background.summarization",
"routes.audit_routes",
):
diff --git a/server/tests/conftest.py b/server/tests/conftest.py
index c261a2466..897942d36 100644
--- a/server/tests/conftest.py
+++ b/server/tests/conftest.py
@@ -40,7 +40,7 @@
# stubbing them with MagicMock would silently mask real failures.
_OPTIONAL_PACKAGES = (
"neo4j", "casbin", "casbin_sqlalchemy_adapter", "sqlalchemy",
- "hvac", "redis", "celery", "weaviate", "flask_socketio",
+ "hvac", "redis", "celery", "flask_socketio",
"flask_cors", "langchain", "langgraph", "requests", "tiktoken",
"dotenv", "flask",
"langchain_core", "langchain_core.tools", "langchain_core.language_models",
diff --git a/server/tests/services/correlation/test_similarity_strategy.py b/server/tests/services/correlation/test_similarity_strategy.py
index 49c89ffc8..897d63a90 100644
--- a/server/tests/services/correlation/test_similarity_strategy.py
+++ b/server/tests/services/correlation/test_similarity_strategy.py
@@ -7,7 +7,7 @@
from services.correlation.strategies.similarity import SimilarityStrategy
-class TestSimilarityStrategyWithVectors:
+class TestSimilarityStrategyWithEmbeddings:
"""Tests with mocked embedding client for vector similarity."""
def setup_method(self):
@@ -17,6 +17,7 @@ def setup_method(self):
def test_identical_texts_high_cosine_similarity(self, mock_get_client):
"""Identical texts should have cosine similarity of 1.0."""
mock_client = MagicMock()
+ mock_client.is_configured = True
mock_client.embed.return_value = [0.5, 0.5, 0.5, 0.5]
mock_get_client.return_value = mock_client
@@ -32,6 +33,7 @@ def test_identical_texts_high_cosine_similarity(self, mock_get_client):
def test_similar_texts_high_score(self, mock_get_client):
"""Similar texts should have high cosine similarity."""
mock_client = MagicMock()
+ mock_client.is_configured = True
mock_client.embed.side_effect = [
[0.8, 0.4, 0.2, 0.1],
[0.75, 0.45, 0.25, 0.05],
@@ -50,6 +52,7 @@ def test_similar_texts_high_score(self, mock_get_client):
def test_different_texts_low_score(self, mock_get_client):
"""Unrelated texts should have low cosine similarity."""
mock_client = MagicMock()
+ mock_client.is_configured = True
mock_client.embed.side_effect = [
[0.9, 0.1, 0.0, 0.0],
[0.0, 0.0, 0.1, 0.9],
@@ -72,9 +75,25 @@ def setup_method(self):
self.strategy = SimilarityStrategy()
@patch("services.correlation.strategies.similarity.get_embedding_client")
- def test_fallback_to_jaccard_on_embedding_failure(self, mock_get_client):
- """Falls back to Jaccard when embedding service unavailable."""
+ def test_fallback_when_unconfigured(self, mock_get_client):
+ """Falls back to Jaccard when no EMBEDDING_MODEL is set."""
mock_client = MagicMock()
+ mock_client.is_configured = False
+ mock_get_client.return_value = mock_client
+
+ score = self.strategy.score(
+ alert_title="High CPU usage on payment service",
+ alert_service="payment-service",
+ incident_title="High CPU usage on payment service",
+ incident_services=["payment-service"],
+ )
+ assert score >= 0.8
+
+ @patch("services.correlation.strategies.similarity.get_embedding_client")
+ def test_fallback_on_embedding_failure(self, mock_get_client):
+ """Falls back to Jaccard when embedding API returns None."""
+ mock_client = MagicMock()
+ mock_client.is_configured = True
mock_client.embed.return_value = None
mock_get_client.return_value = mock_client
@@ -90,7 +109,7 @@ def test_fallback_to_jaccard_on_embedding_failure(self, mock_get_client):
def test_fallback_partial_overlap(self, mock_get_client):
"""Fallback Jaccard gives mid-range score for partial overlap."""
mock_client = MagicMock()
- mock_client.embed.return_value = None
+ mock_client.is_configured = False
mock_get_client.return_value = mock_client
score = self.strategy.score(
@@ -103,7 +122,7 @@ def test_fallback_partial_overlap(self, mock_get_client):
class TestSimilarityStrategyServiceMatching:
- """Tests for service name matching (independent of vector similarity)."""
+ """Tests for service name matching."""
def setup_method(self):
self.strategy = SimilarityStrategy()
@@ -112,6 +131,7 @@ def setup_method(self):
def test_exact_service_match_boosts_score(self, mock_get_client):
"""Exact service match gives full service score."""
mock_client = MagicMock()
+ mock_client.is_configured = True
mock_client.embed.return_value = [0.5, 0.5, 0.5, 0.5]
mock_get_client.return_value = mock_client
@@ -127,6 +147,7 @@ def test_exact_service_match_boosts_score(self, mock_get_client):
def test_different_service_reduces_score(self, mock_get_client):
"""Different service name reduces the service component."""
mock_client = MagicMock()
+ mock_client.is_configured = True
mock_client.embed.return_value = [0.5, 0.5, 0.5, 0.5]
mock_get_client.return_value = mock_client
@@ -175,6 +196,7 @@ def test_empty_incident_title_returns_zero(self):
def test_empty_service_still_scores_on_title(self, mock_get_client):
"""Empty services should not crash; score uses title only."""
mock_client = MagicMock()
+ mock_client.is_configured = True
mock_client.embed.return_value = [0.5, 0.5, 0.5, 0.5]
mock_get_client.return_value = mock_client
@@ -252,3 +274,57 @@ def test_jaccard_partial_overlap(self):
"""Partial overlap gives mid-range Jaccard."""
sim = SimilarityStrategy._jaccard({"a", "b", "c"}, {"b", "c", "d"})
assert sim == pytest.approx(0.5)
+
+
+class TestEmbeddingClientParsing:
+ """Tests for EMBEDDING_MODEL env var parsing."""
+
+ @patch.dict("os.environ", {"EMBEDDING_MODEL": "openai/text-embedding-3-small"})
+ def test_parses_openai_model(self):
+ """Correctly parses openai provider and model."""
+ from services.correlation.embedding_client import _parse_embedding_model
+ provider, model = _parse_embedding_model()
+ assert provider == "openai"
+ assert model == "text-embedding-3-small"
+
+ @patch.dict("os.environ", {"EMBEDDING_MODEL": "google/text-embedding-004"})
+ def test_parses_google_model(self):
+ """Correctly parses google provider and model."""
+ from services.correlation.embedding_client import _parse_embedding_model
+ provider, model = _parse_embedding_model()
+ assert provider == "google"
+ assert model == "text-embedding-004"
+
+ @patch.dict("os.environ", {"EMBEDDING_MODEL": "bedrock/amazon.titan-embed-text-v2:0"})
+ def test_parses_bedrock_model(self):
+ """Correctly parses bedrock provider and model."""
+ from services.correlation.embedding_client import _parse_embedding_model
+ provider, model = _parse_embedding_model()
+ assert provider == "bedrock"
+ assert model == "amazon.titan-embed-text-v2:0"
+
+ @patch.dict("os.environ", {"EMBEDDING_MODEL": ""})
+ def test_empty_returns_none(self):
+ """Empty EMBEDDING_MODEL returns (None, None)."""
+ from services.correlation.embedding_client import _parse_embedding_model
+ provider, model = _parse_embedding_model()
+ assert provider is None
+ assert model is None
+
+ @patch.dict("os.environ", {}, clear=False)
+ def test_unset_returns_none(self):
+ """Unset EMBEDDING_MODEL returns (None, None)."""
+ import os
+ os.environ.pop("EMBEDDING_MODEL", None)
+ from services.correlation.embedding_client import _parse_embedding_model
+ provider, model = _parse_embedding_model()
+ assert provider is None
+ assert model is None
+
+ @patch.dict("os.environ", {"EMBEDDING_MODEL": "no-slash-here"})
+ def test_no_slash_returns_none(self):
+ """Model without provider prefix returns (None, None)."""
+ from services.correlation.embedding_client import _parse_embedding_model
+ provider, model = _parse_embedding_model()
+ assert provider is None
+ assert model is None
diff --git a/server/utils/auth/enforcer.py b/server/utils/auth/enforcer.py
index 958378e67..83e909e1b 100644
--- a/server/utils/auth/enforcer.py
+++ b/server/utils/auth/enforcer.py
@@ -40,7 +40,7 @@
("viewer", "*", "connectors", "read"),
("viewer", "*", "chat", "read"),
("viewer", "*", "chat", "write"),
- ("viewer", "*", "knowledge_base", "read"),
+ ("viewer", "*", "memory", "read"),
("viewer", "*", "ssh_keys", "read"),
("viewer", "*", "vms", "read"),
("viewer", "*", "llm_usage", "read"),
@@ -55,7 +55,7 @@
("editor", "*", "incidents", "write"),
("editor", "*", "postmortems", "write"),
("editor", "*", "artifacts", "write"),
- ("editor", "*", "knowledge_base", "write"),
+ ("editor", "*", "memory", "write"),
("editor", "*", "ssh_keys", "write"),
("editor", "*", "vms", "write"),
("editor", "*", "rca_emails", "write"),
diff --git a/server/utils/db/db_utils.py b/server/utils/db/db_utils.py
index 5d21239aa..ba28286c6 100644
--- a/server/utils/db/db_utils.py
+++ b/server/utils/db/db_utils.py
@@ -1216,23 +1216,6 @@ def initialize_tables():
CREATE INDEX IF NOT EXISTS idx_kb_documents_user_id ON knowledge_base_documents(user_id);
CREATE INDEX IF NOT EXISTS idx_kb_documents_status ON knowledge_base_documents(status);
""",
- "incident_feedback": """
- CREATE TABLE IF NOT EXISTS incident_feedback (
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
- user_id VARCHAR(255) NOT NULL,
- org_id VARCHAR(255),
- incident_id UUID NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,
- feedback_type VARCHAR(20) NOT NULL,
- comment TEXT,
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
- );
-
- CREATE UNIQUE INDEX IF NOT EXISTS idx_incident_feedback_user_incident
- ON incident_feedback(user_id, incident_id);
-
- CREATE INDEX IF NOT EXISTS idx_incident_feedback_user_id ON incident_feedback(user_id);
- CREATE INDEX IF NOT EXISTS idx_incident_feedback_type ON incident_feedback(feedback_type);
- """,
"postmortems": """
CREATE TABLE IF NOT EXISTS postmortems (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -1264,13 +1247,13 @@ def initialize_tables():
user_id VARCHAR(255) NOT NULL,
title VARCHAR(500) NOT NULL,
content TEXT,
+ category VARCHAR(50) NOT NULL,
+ description TEXT,
last_edited_by VARCHAR(20) NOT NULL DEFAULT 'agent',
current_version_id UUID,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-
- CREATE UNIQUE INDEX IF NOT EXISTS idx_artifacts_org_title ON artifacts(org_id, title);
""",
"artifact_versions": """
CREATE TABLE IF NOT EXISTS artifact_versions (
@@ -1453,7 +1436,6 @@ def initialize_tables():
# so they don't need RLS - incident_alerts is protected separately for safety
rls_tables.append("incidents")
rls_tables.append("incident_alerts")
- rls_tables.append("incident_feedback")
rls_tables.append("postmortems")
rls_tables.append("postmortem_exports")
rls_tables.append("incident_lifecycle_events")
@@ -2712,7 +2694,7 @@ def initialize_tables():
"k8s_pod_metrics", "k8s_node_metrics",
"cloud_billing_usage", "provider_metrics",
"knowledge_base_memory", "knowledge_base_documents",
- "incident_feedback", "postmortems",
+ "postmortems",
"incident_lifecycle_events",
"connected_repos",
]
@@ -3021,7 +3003,166 @@ def initialize_tables():
logging.warning(f"Error de-duplicating organization names: {e}")
conn.rollback()
+ # artifacts: add category + description columns for memory system
+ try:
+ cursor.execute("""
+ ALTER TABLE artifacts ADD COLUMN IF NOT EXISTS category VARCHAR(50);
+ ALTER TABLE artifacts ADD COLUMN IF NOT EXISTS description TEXT;
+ """)
+ # Set default before NOT NULL so new rows inserted mid-migration are safe
+ cursor.execute("""
+ ALTER TABLE artifacts ALTER COLUMN category SET DEFAULT 'context';
+ """)
+ # FORCE RLS is already active — temporarily disable so backfill can reach all rows
+ cursor.execute("ALTER TABLE artifacts NO FORCE ROW LEVEL SECURITY")
+ cursor.execute("""
+ UPDATE artifacts SET category = 'context'
+ WHERE category IS NULL OR category = ''
+ """)
+ cursor.execute("ALTER TABLE artifacts FORCE ROW LEVEL SECURITY")
+ # Now enforce NOT NULL — all existing rows guaranteed non-null
+ cursor.execute("""
+ ALTER TABLE artifacts ALTER COLUMN category SET NOT NULL;
+ """)
+ # Replace legacy (org_id, title) uniqueness with (org_id, category, title)
+ cursor.execute("""
+ DROP INDEX IF EXISTS idx_artifacts_org_title;
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_artifacts_org_cat_title
+ ON artifacts(org_id, category, title);
+ CREATE INDEX IF NOT EXISTS idx_artifacts_org_category
+ ON artifacts(org_id, category);
+ """)
+ conn.commit()
+ except Exception as e:
+ logging.error(f"CRITICAL: Failed to migrate artifacts table — memory writes will fail: {e}")
+ conn.rollback()
+ raise
+
+ # artifacts: add incident_id + generation_session_id for postmortem unification
+ try:
+ cursor.execute("""
+ ALTER TABLE artifacts ADD COLUMN IF NOT EXISTS incident_id UUID REFERENCES incidents(id) ON DELETE CASCADE;
+ ALTER TABLE artifacts ADD COLUMN IF NOT EXISTS generation_session_id VARCHAR(255);
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_artifacts_incident_id ON artifacts(incident_id) WHERE incident_id IS NOT NULL;
+ """)
+ # Drop FK constraint on postmortem_exports so it can reference artifact IDs
+ cursor.execute("""
+ ALTER TABLE postmortem_exports DROP CONSTRAINT IF EXISTS postmortem_exports_postmortem_id_fkey;
+ """)
+ conn.commit()
+ except Exception as e:
+ logging.warning(f"Error adding incident_id/generation_session_id to artifacts: {e}")
+ conn.rollback()
+
+ # Migrate postmortem data into artifacts (idempotent)
+ # Temporarily relax RLS on source tables so the migration works on managed Postgres
+ # (where the app user doesn't have BYPASSRLS)
+ try:
+ cursor.execute("ALTER TABLE postmortems DISABLE ROW LEVEL SECURITY")
+ cursor.execute("ALTER TABLE incidents DISABLE ROW LEVEL SECURITY")
+ conn.commit()
+
+ cursor.execute("""
+ SELECT DISTINCT COALESCE(p.org_id, i.org_id) as org_id
+ FROM postmortems p
+ JOIN incidents i ON i.id = p.incident_id
+ WHERE p.content IS NOT NULL
+ AND COALESCE(p.org_id, i.org_id) IS NOT NULL
+ """)
+ pm_orgs = [row[0] for row in cursor.fetchall()]
+ conn.commit()
+
+ migrated_count = 0
+ for pm_org_id in pm_orgs:
+ cursor.execute("SET myapp.current_org_id = %s", (pm_org_id,))
+ cursor.execute("""
+ INSERT INTO artifacts (org_id, user_id, title, content, category, description,
+ incident_id, generation_session_id, last_edited_by,
+ created_at, updated_at)
+ SELECT COALESCE(p.org_id, i.org_id), p.user_id,
+ COALESCE('Postmortem: ' || i.alert_title || ' [' || LEFT(p.incident_id::text, 8) || ']',
+ 'Postmortem (' || p.incident_id || ')'),
+ p.content, 'postmortem',
+ 'Generated postmortem for incident: ' || COALESCE(i.alert_title, p.incident_id::text),
+ p.incident_id, p.generation_session_id, 'system',
+ p.generated_at, p.updated_at
+ FROM postmortems p
+ JOIN incidents i ON i.id = p.incident_id
+ WHERE p.content IS NOT NULL
+ AND COALESCE(p.org_id, i.org_id) = %s
+ ON CONFLICT (org_id, category, title) DO NOTHING
+ """, (pm_org_id,))
+ migrated_count += cursor.rowcount
+ conn.commit()
+
+ cursor.execute("RESET myapp.current_org_id")
+ # Re-enable RLS (will be re-forced by the RLS setup if it runs again)
+ cursor.execute("ALTER TABLE postmortems ENABLE ROW LEVEL SECURITY")
+ cursor.execute("ALTER TABLE postmortems FORCE ROW LEVEL SECURITY")
+ cursor.execute("ALTER TABLE incidents ENABLE ROW LEVEL SECURITY")
+ cursor.execute("ALTER TABLE incidents FORCE ROW LEVEL SECURITY")
+ conn.commit()
+ if migrated_count > 0:
+ logging.info(f"Migrated {migrated_count} postmortems into artifacts table.")
+ except Exception as e:
+ logging.warning(f"Error migrating postmortems to artifacts: {e}")
+ conn.rollback()
+
+ # Auto-trigger memory migration if old KB tables still have data
+ _should_trigger_migration = False
+ try:
+ cursor.execute("""
+ SELECT EXISTS (
+ SELECT 1 FROM information_schema.tables
+ WHERE table_name = 'knowledge_base_memory'
+ )
+ """)
+ old_tables_exist = cursor.fetchone()[0]
+
+ if old_tables_exist:
+ cursor.execute("""
+ SELECT EXISTS (
+ SELECT 1 FROM knowledge_base_memory
+ WHERE content IS NOT NULL AND content != ''
+ LIMIT 1
+ )
+ OR EXISTS (
+ SELECT 1 FROM infrastructure_context
+ WHERE content IS NOT NULL AND content != ''
+ LIMIT 1
+ )
+ OR EXISTS (
+ SELECT 1 FROM knowledge_base_documents
+ WHERE status IN ('processed', 'ready')
+ AND storage_path IS NOT NULL
+ LIMIT 1
+ )
+ """)
+ has_source_data = cursor.fetchone()[0]
+
+ cursor.execute("""
+ SELECT EXISTS (
+ SELECT 1 FROM artifacts
+ WHERE category IN ('context', 'infrastructure', 'runbook')
+ LIMIT 1
+ )
+ """)
+ already_migrated = cursor.fetchone()[0]
+
+ if has_source_data and not already_migrated:
+ _should_trigger_migration = True
+ except Exception as e:
+ logging.warning(f"Error checking/triggering memory migration: {e}")
+ conn.rollback()
+
conn.commit()
+
+ # Enqueue migration AFTER commit so the worker sees the final schema
+ if _should_trigger_migration:
+ from services.memory.migration_task import migrate_kb_to_memory
+ migrate_kb_to_memory.delay()
+ logging.info("Triggered automatic KB → memory migration task")
+
logging.info("Database tables initialized successfully.")
cursor.close()
diff --git a/server/utils/validation.py b/server/utils/validation.py
index 4c4c2ab08..a8e5d81cd 100644
--- a/server/utils/validation.py
+++ b/server/utils/validation.py
@@ -10,3 +10,8 @@ def is_valid_uuid(value) -> bool:
return True
except (ValueError, AttributeError, TypeError):
return False
+
+
+def strip_nul(text: str) -> str:
+ """Remove NUL (0x00) bytes that PostgreSQL text columns reject."""
+ return text.replace("\x00", "")
diff --git a/website/docs/architecture/overview.md b/website/docs/architecture/overview.md
index d87ba1dab..e64f5da8d 100644
--- a/website/docs/architecture/overview.md
+++ b/website/docs/architecture/overview.md
@@ -30,10 +30,10 @@ Aurora is a containerized application consisting of multiple services orchestrat
│
┌─────────────┬────────────┼────────────┬─────────────┐
▼ ▼ ▼ ▼ ▼
-┌─────────────┐ ┌─────────┐ ┌──────────┐ ┌─────────┐ ┌───────────┐
-│ PostgreSQL │ │ Redis │ │ Weaviate │ │ Vault │ │ SeaweedFS │
-│ :5432 │ │ :6379 │ │ :8080 │ │ :8200 │ │ :8333 │
-└─────────────┘ └─────────┘ └──────────┘ └─────────┘ └───────────┘
+┌─────────────┐ ┌─────────┐ ┌─────────┐ ┌───────────┐ ┌───────────┐
+│ PostgreSQL │ │ Redis │ │ Vault │ │ SeaweedFS │ │ Memgraph │
+│ :5432 │ │ :6379 │ │ :8200 │ │ :8333 │ │ :7687 │
+└─────────────┘ └─────────┘ └─────────┘ └───────────┘ └───────────┘
```
## Core Components
@@ -78,11 +78,6 @@ Aurora is a containerized application consisting of multiple services orchestrat
- **Port**: 6379
- **Purpose**: Celery task queue, session cache, real-time subscriptions
-### Weaviate
-
-- **Port**: 8080
-- **Purpose**: Vector database for semantic search over logs and documents
-
### HashiCorp Vault
- **Port**: 8200
diff --git a/website/docs/architecture/services.md b/website/docs/architecture/services.md
index a64414280..75b5fadcb 100644
--- a/website/docs/architecture/services.md
+++ b/website/docs/architecture/services.md
@@ -15,7 +15,6 @@ Detailed reference for all Aurora services.
| Chatbot | 5006 | WebSocket | Real-time chat |
| PostgreSQL | 5432 | TCP | Primary database |
| Redis | 6379 | TCP | Task queue / cache |
-| Weaviate | 8080 | HTTP | Vector database |
| Vault | 8200 | HTTP | Secrets management |
| SeaweedFS S3 | 8333 | HTTP | Object storage API |
| SeaweedFS UI | 8888 | HTTP | File browser |
@@ -141,20 +140,6 @@ In-memory data store.
docker exec -it aurora-redis-1 redis-cli
```
-## Weaviate
-
-Vector database for semantic search.
-
-### Uses
-- Log embeddings
-- Document search
-- Similarity queries
-
-### API
-```
-http://localhost:8080/v1
-```
-
## Vault
Secrets management.
diff --git a/website/docs/configuration/aws-secrets-manager.md b/website/docs/configuration/aws-secrets-manager.md
index 091a2862e..d00942259 100644
--- a/website/docs/configuration/aws-secrets-manager.md
+++ b/website/docs/configuration/aws-secrets-manager.md
@@ -82,7 +82,7 @@ eksctl create iamserviceaccount \
### 2. Install the EBS CSI Driver
-Aurora's stateful services (Postgres, Redis, Weaviate) require persistent volumes. A fresh EKS cluster needs the EBS CSI driver and a default StorageClass:
+Aurora's stateful services (Postgres, Redis) require persistent volumes. A fresh EKS cluster needs the EBS CSI driver and a default StorageClass:
```bash
# Create IRSA for the driver
diff --git a/website/docs/configuration/environment.md b/website/docs/configuration/environment.md
index a3363beb5..0880a9108 100644
--- a/website/docs/configuration/environment.md
+++ b/website/docs/configuration/environment.md
@@ -564,20 +564,6 @@ SMTP_FROM_NAME=Aurora
| `USE_UNTRUSTED_NODES` | - | Allow untrusted nodes |
| `NEXT_PUBLIC_KUBECTL_AGENT_CHART_URL` | - | Helm chart URL for kubectl agent |
-## Weaviate (Vector Database)
-
-| Variable | Default | Description |
-|----------|---------|-------------|
-| `WEAVIATE_HOST` | `weaviate` | Weaviate server host |
-| `WEAVIATE_PORT` | `8080` | Weaviate HTTP port |
-| `WEAVIATE_GRPC_PORT` | `50051` | Weaviate gRPC port |
-
-```bash
-WEAVIATE_HOST=weaviate
-WEAVIATE_PORT=8080
-WEAVIATE_GRPC_PORT=50051
-```
-
## MCP Server
| Variable | Default | Description |
diff --git a/website/docs/deployment/docker-compose.md b/website/docs/deployment/docker-compose.md
index d98eacb5d..54a548030 100644
--- a/website/docs/deployment/docker-compose.md
+++ b/website/docs/deployment/docker-compose.md
@@ -162,10 +162,10 @@ All services run in Docker containers:
┌───────────────────┼─────────────────────────┐
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
-┌───────┐ ┌─────────┐ ┌──────────┐ ┌───────┐ ┌─────────┐
-│Postgres│ │ Redis │ │ Weaviate │ │ Vault │ │SeaweedFS│
-│ :5432 │ │ :6379 │ │ :8080 │ │ :8200 │ │ :8333 │
-└───────┘ └─────────┘ └──────────┘ └───────┘ └─────────┘
+┌───────┐ ┌─────────┐ ┌───────┐ ┌─────────┐ ┌───────────┐
+│Postgres│ │ Redis │ │ Vault │ │SeaweedFS│ │ Memgraph │
+│ :5432 │ │ :6379 │ │ :8200 │ │ :8333 │ │ :7687 │
+└───────┘ └─────────┘ └───────┘ └─────────┘ └───────────┘
```
## Data Persistence
@@ -176,7 +176,6 @@ Data is stored in Docker volumes and persists across container restarts:
|--------|----------|
| `aurora_postgres_data` | PostgreSQL database |
| `aurora_redis_data` | Redis data |
-| `aurora_weaviate_data` | Vector embeddings |
| `aurora_vault_data` | Vault secrets |
| `aurora_seaweedfs_data` | Object storage |
diff --git a/website/docs/deployment/eks-setup.md b/website/docs/deployment/eks-setup.md
index a5128f829..19a9d58b2 100644
--- a/website/docs/deployment/eks-setup.md
+++ b/website/docs/deployment/eks-setup.md
@@ -75,7 +75,7 @@ eksctl create cluster \
## Step 2: Install EBS CSI Driver
-EKS does **not** ship with a working storage driver. Without this, all database pods (Postgres, Redis, Vault, Weaviate) will be stuck in `Pending`.
+EKS does **not** ship with a working storage driver. Without this, all database pods (Postgres, Redis, Vault) will be stuck in `Pending`.
```bash
export AWS_REGION="us-east-1"
diff --git a/website/docs/deployment/kubernetes.md b/website/docs/deployment/kubernetes.md
index f817fbff4..8a4c8b686 100644
--- a/website/docs/deployment/kubernetes.md
+++ b/website/docs/deployment/kubernetes.md
@@ -20,7 +20,7 @@ Deploy Aurora on any Kubernetes cluster using Helm.
- **GCP GKE / Azure AKS:** Create a cluster with default settings
:::note Third-party images
-Aurora deploys several third-party images from public registries. Your nodes must be able to pull: `postgres:15-alpine`, `redis:7-alpine`, `hashicorp/vault:1.15`, `cr.weaviate.io/semitechnologies/weaviate:1.27.6`, `searxng/searxng:*`, `memgraph/memgraph-mage:3.8.1`, `cr.weaviate.io/semitechnologies/transformers-inference:*`. Optional components (e.g. `services.minio.enabled: true`) may pull additional images. For air-gapped clusters, mirror these to a private registry and review enabled services in your `values.yaml`.
+Aurora deploys several third-party images from public registries. Your nodes must be able to pull: `postgres:15-alpine`, `redis:7-alpine`, `hashicorp/vault:1.15`, `searxng/searxng:*`, `memgraph/memgraph-mage:3.8.1`. Optional components (e.g. `services.minio.enabled: true`) may pull additional images. For air-gapped clusters, mirror these to a private registry and review enabled services in your `values.yaml`.
:::
### Required tools
diff --git a/website/docs/getting-started/quickstart.md b/website/docs/getting-started/quickstart.md
index f639839e7..c47466ffd 100644
--- a/website/docs/getting-started/quickstart.md
+++ b/website/docs/getting-started/quickstart.md
@@ -192,7 +192,6 @@ aurora-chatbot running
aurora-frontend running
postgres running
redis running
-weaviate running
vault running
seaweedfs-master running
seaweedfs-filer running
@@ -230,7 +229,6 @@ Aurora starts these services:
| Chatbot | 5006 | WebSocket server for real-time chat |
| PostgreSQL | 5432 | Primary database |
| Redis | 6379 | Task queue and cache |
-| Weaviate | 8080 | Vector database for semantic search |
| Vault | 8200 | Secrets management |
| SeaweedFS | 8333 | S3-compatible object storage |
diff --git a/website/docs/integrations/mcp.md b/website/docs/integrations/mcp.md
index 67d9ca522..83946c2e2 100644
--- a/website/docs/integrations/mcp.md
+++ b/website/docs/integrations/mcp.md
@@ -78,7 +78,7 @@ URI-fetched reference data — costs zero tokens until requested:
| `aurora://catalog/skills` | All skills, with per-user connection status |
| `aurora://incidents/recent` | Last 20 incidents (titles only, no full bodies) |
| `aurora://runbooks/index` | Runbook index per connected doc connector |
-| `aurora://health` | Live system health: database, Redis, Weaviate, Celery |
+| `aurora://health` | Live system health: database, Redis, Celery |
## Prompts
diff --git a/website/docs/troubleshooting.md b/website/docs/troubleshooting.md
index 6a6060bb1..91a3d2545 100644
--- a/website/docs/troubleshooting.md
+++ b/website/docs/troubleshooting.md
@@ -108,7 +108,6 @@ Ensure these are free:
- 5006 (Chatbot)
- 5432 (PostgreSQL)
- 6379 (Redis)
-- 8080 (Weaviate)
- 8200 (Vault)
- 8333 (SeaweedFS)