/**
* Memory Repository
*
* Manages memory creation, storage, and retrieval with transaction support.
* Integrates embedding generation, waypoint graph building, and metadata extraction.
*
* Requirements: 2.1, 2.2, 2.3, 2.4, 2.5
*/
import { randomUUID } from "crypto";
import { createHash } from "node:crypto";
import type { PoolClient } from "pg";
import { DatabaseConnectionManager } from "../database/connection-manager";
import { GenericLRUCache } from "../embeddings/cache";
import { EmbeddingEngine } from "../embeddings/embedding-engine";
import { EmbeddingStorage } from "../embeddings/embedding-storage";
import { Link, LinkType } from "../graph/types";
import { WaypointGraphBuilder } from "../graph/waypoint-builder";
import { FullTextSearchEngine } from "../search/full-text-search-engine";
import type { FullTextSearchQuery, FullTextSearchResponse } from "../search/types";
import { Logger } from "../utils/logger.js";
import { MetadataMerger, type MetadataUpdate } from "./metadata-merger.js";
import {
Memory,
MemoryContent,
MemoryCreationError,
MemoryMetadata,
MemorySectorType,
MemoryTransactionError,
MemoryUpdateError,
MemoryValidationError,
SECTOR_DECAY_RATES,
VALID_SECTORS,
} from "./types";
/**
* Repository for memory operations
*/
export class MemoryRepository {
private fullTextSearchEngine: FullTextSearchEngine;
private searchCache: GenericLRUCache<import("./types").SearchResult>;
private metadataMerger: MetadataMerger;
constructor(
private db: DatabaseConnectionManager,
private embeddingEngine: EmbeddingEngine,
private graphBuilder: WaypointGraphBuilder,
private embeddingStorage: EmbeddingStorage
) {
this.fullTextSearchEngine = new FullTextSearchEngine(db);
this.searchCache = new GenericLRUCache(10000, 300000); // 10k entries, 5 min TTL
this.metadataMerger = new MetadataMerger();
}
/**
* Create a new memory with embeddings, metadata, and waypoint connections
*
* Requirements:
* - 2.1: Memory creation with all required fields
* - 2.2: Automatic embedding generation
* - 2.3: Waypoint connection creation
* - 2.4: Metadata extraction and storage
* - 2.5: Initial strength, salience, importance values
*
* @param content - Memory content and user context
* @param metadata - Optional metadata (auto-extracted if not provided)
* @returns Complete memory object with embeddings and links
*/
async create(content: MemoryContent, metadata?: MemoryMetadata): Promise<Memory> {
// Validate input
this.validateContent(content);
if (metadata) {
this.validateMetadata(metadata);
}
let client: PoolClient | undefined;
try {
// Begin transaction for atomic memory creation
client = await this.db.beginTransaction();
// Generate unique memory ID
const memoryId = randomUUID();
const now = new Date();
// Calculate initial values
const salience = this.calculateSalience(content.content, metadata);
const decayRate = SECTOR_DECAY_RATES[content.primarySector];
const strength = 1.0; // Initial strength always 1.0
const accessCount = 0; // Initial access count always 0
// Extract or use provided metadata
const finalMetadata = this.extractMetadata(content.content, metadata);
// Create memory record in database
const memory: Memory = {
id: memoryId,
content: content.content,
createdAt: now,
lastAccessed: now,
accessCount,
salience,
decayRate,
strength,
userId: content.userId,
sessionId: content.sessionId,
primarySector: content.primarySector,
metadata: finalMetadata,
};
// Insert into memories table
await this.insertMemoryRecord(client, memory);
// Generate and store embeddings
try {
const embeddings = await this.embeddingEngine.generateAllSectorEmbeddings({
text: content.content,
sector: content.primarySector as unknown as import("../embeddings/types").MemorySector,
});
memory.embeddings = embeddings;
// Store embeddings in memory_embeddings table (using transaction client)
await this.embeddingStorage.storeEmbeddings(memoryId, embeddings, "default", client);
} catch (error) {
throw new MemoryCreationError("Embedding generation failed", error as Error);
}
// Store metadata in memory_metadata table
await this.insertMetadataRecord(client, memoryId, finalMetadata);
// Create waypoint connections
const links = await this.createWaypointConnections(client, memory);
// Store waypoint connections in database
if (links.length > 0) {
await this.storeWaypointLinks(client, links);
}
memory.links = links;
// Commit transaction
await this.db.commitTransaction(client);
// Invalidate search cache after successful creation
this.invalidateSearchCache();
return memory;
} catch (error) {
// Rollback transaction on any error
if (client) {
await this.db.rollbackTransaction(client);
}
// Re-throw with context
if (error instanceof MemoryValidationError || error instanceof MemoryCreationError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes("Connection failed")) {
throw new MemoryTransactionError(
"Database connection failed",
"begin_transaction",
error as Error
);
}
throw new MemoryCreationError("Memory creation failed", error as Error);
}
}
/**
* Validate memory content
*/
private validateContent(content: MemoryContent): void {
if (!content.content || content.content.trim().length === 0) {
throw new MemoryValidationError("Content cannot be empty", "content", content.content);
}
if (!content.userId || content.userId.trim().length === 0) {
throw new MemoryValidationError("userId is required", "userId", content.userId);
}
if (!VALID_SECTORS.includes(content.primarySector)) {
throw new MemoryValidationError(
"Invalid memory sector",
"primarySector",
content.primarySector
);
}
}
/**
* Validate metadata
*/
private validateMetadata(metadata: MemoryMetadata): void {
if (metadata.importance !== undefined) {
if (metadata.importance < 0 || metadata.importance > 1) {
throw new MemoryValidationError(
"Importance must be between 0 and 1",
"importance",
metadata.importance
);
}
}
}
/**
* Calculate salience from content and metadata
* Salience indicates how attention-grabbing or significant the memory is
*/
private calculateSalience(content: string, metadata?: MemoryMetadata): number {
let salience = 0.5; // Default baseline
// High-salience keywords boost salience
const highSalienceWords = [
"important",
"urgent",
"critical",
"essential",
"vital",
"crucial",
"emergency",
"priority",
"significant",
"major",
];
const contentLower = content.toLowerCase();
const matchCount = highSalienceWords.filter((word) => contentLower.includes(word)).length;
// Each match adds 0.1, capped at 1.0
salience = Math.min(1.0, salience + matchCount * 0.1);
// Metadata importance influences salience
if (metadata?.importance !== undefined) {
salience = (salience + metadata.importance) / 2;
}
return Math.max(0, Math.min(1.0, salience));
}
/**
* Extract metadata from content or use provided metadata
*/
private extractMetadata(content: string, provided?: MemoryMetadata): MemoryMetadata {
const metadata: MemoryMetadata = {
keywords: provided?.keywords ?? this.extractKeywords(content),
tags: provided?.tags ?? [],
category: provided?.category ?? "general",
context: provided?.context ?? "",
importance: provided?.importance ?? 0.5,
isAtomic: provided?.isAtomic ?? true,
parentId: provided?.parentId,
};
return metadata;
}
/**
* Extract keywords from content
* Simple extraction based on word frequency and length
*/
private extractKeywords(content: string): string[] {
// Remove punctuation and split into words
const words = content
.toLowerCase()
.replace(/[^\w\s]/g, " ")
.split(/\s+/)
.filter((word) => word.length > 3); // Only words longer than 3 chars
// Common stop words to filter out
const stopWords = new Set([
"this",
"that",
"with",
"from",
"have",
"been",
"were",
"will",
"would",
"could",
"should",
"about",
"which",
"their",
"there",
"these",
"those",
]);
// Count word frequency
const wordFreq = new Map<string, number>();
words.forEach((word) => {
if (!stopWords.has(word)) {
wordFreq.set(word, (wordFreq.get(word) ?? 0) + 1);
}
});
// Get top keywords by frequency
const keywords = Array.from(wordFreq.entries())
.sort((a, b) => b[1] - a[1])
.slice(0, 10)
.map(([word]) => word);
return keywords;
}
/**
* Insert memory record into database
*/
private async insertMemoryRecord(client: PoolClient, memory: Memory): Promise<void> {
const query = `
INSERT INTO memories (
id, content, created_at, last_accessed, access_count,
salience, decay_rate, strength, user_id, session_id, primary_sector
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`;
const values = [
memory.id,
memory.content,
memory.createdAt,
memory.lastAccessed,
memory.accessCount,
memory.salience,
memory.decayRate,
memory.strength,
memory.userId,
memory.sessionId,
memory.primarySector,
];
await client.query(query, values);
}
/**
* Insert metadata record into database
*/
private async insertMetadataRecord(
client: PoolClient,
memoryId: string,
metadata: MemoryMetadata
): Promise<void> {
const query = `
INSERT INTO memory_metadata (
memory_id, keywords, tags, category, context, importance, is_atomic, parent_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`;
const values = [
memoryId,
metadata.keywords ?? [],
metadata.tags ?? [],
metadata.category ?? null,
metadata.context ?? null,
metadata.importance ?? 0.5,
metadata.isAtomic ?? true,
metadata.parentId ?? null,
];
await client.query(query, values);
}
/**
* Create waypoint connections to similar memories
*/
private async createWaypointConnections(client: PoolClient, memory: Memory): Promise<Link[]> {
try {
// Find existing memories for the same user
const candidatesQuery = `
SELECT m.*, me.embedding
FROM memories m
LEFT JOIN memory_embeddings me ON m.id = me.memory_id AND me.sector = $1
WHERE m.user_id = $2 AND m.id != $3
ORDER BY m.created_at DESC
LIMIT 100
`;
const result = await client.query(candidatesQuery, [
memory.primarySector,
memory.userId,
memory.id,
]);
if (result.rows.length === 0) {
// First memory for this user, no connections to create
return [];
}
// Convert rows to Memory objects
const candidates = result.rows.map((row: Record<string, unknown>) => this.rowToMemory(row));
Logger.debug(`Found ${candidates.length} candidates for waypoint connections`);
// Create waypoint links using graph builder
const result2 = await this.graphBuilder.createWaypointLinks(
this.convertToGraphMemory(memory),
candidates.map((c: Memory) => this.convertToGraphMemory(c))
);
Logger.debug(`Created ${result2.links.length} waypoint links`);
return result2.links;
} catch (error) {
// Waypoint connection creation is non-critical, log error and continue
Logger.error("Error creating waypoint connections:", error);
return [];
}
}
/**
* Store waypoint links in the database
* Creates bidirectional connections in memory_links table
*/
private async storeWaypointLinks(client: PoolClient, links: Link[]): Promise<void> {
for (const link of links) {
// Validate no self-links before storing
if (link.sourceId === link.targetId) {
Logger.warn(`Skipping self-link for memory ${link.sourceId}`);
continue;
}
// Store connection using the link's sourceId and targetId
// (links already contain both forward and reverse connections if bidirectional)
await client.query(
`INSERT INTO memory_links (source_id, target_id, link_type, weight)
VALUES ($1, $2, $3, $4)
ON CONFLICT (source_id, target_id) DO UPDATE SET
link_type = $3,
weight = $4,
traversal_count = memory_links.traversal_count + 1`,
[link.sourceId, link.targetId, link.linkType, link.weight]
);
}
}
/**
* Convert our Memory type to graph Memory type
* Includes embeddings when available to avoid transaction isolation issues
*/
private convertToGraphMemory(memory: Memory): import("../graph/types").Memory {
return {
id: memory.id,
content: memory.content,
createdAt: memory.createdAt,
lastAccessed: memory.lastAccessed,
accessCount: memory.accessCount,
salience: memory.salience,
strength: memory.strength,
userId: memory.userId,
sessionId: memory.sessionId,
primarySector: memory.primarySector,
metadata: {
keywords: memory.metadata.keywords ?? [],
tags: memory.metadata.tags ?? [],
category: memory.metadata.category ?? "",
context: memory.metadata.context ?? "",
importance: memory.metadata.importance ?? 0.5,
isAtomic: memory.metadata.isAtomic ?? true,
parentId: memory.metadata.parentId,
},
embeddings: memory.embeddings,
};
}
/**
* Convert database row to Memory object
*/
private rowToMemory(row: Record<string, unknown>): Memory {
return {
id: row.id as string,
content: row.content as string,
createdAt: new Date(row.created_at as string),
lastAccessed: new Date(row.last_accessed as string),
accessCount: row.access_count as number,
salience: row.salience as number,
decayRate: row.decay_rate as number,
strength: row.strength as number,
userId: row.user_id as string,
sessionId: row.session_id as string,
primarySector: row.primary_sector as MemorySectorType,
metadata: {
keywords: [],
tags: [],
category: "",
context: "",
importance: 0.5,
isAtomic: true,
},
};
}
/**
* Retrieve a single memory by ID
*
* Requirements:
* - 2.1: Single memory retrieval
* - 2.2: Include embeddings and metadata
* - 2.3: Include waypoint connections
* - 2.4: User isolation (verify ownership)
* - 2.5: Fast retrieval (<50ms)
*
* @param memoryId - ID of memory to retrieve
* @param userId - User ID for ownership verification
* @returns Memory object with embeddings and links, or null if not found
*/
async retrieve(memoryId: string, userId: string): Promise<Memory | null> {
// Validate input
this.validateMemoryId(memoryId);
this.validateUserId(userId);
let client: PoolClient | null = null;
try {
client = await this.db.getConnection();
// Query memory with metadata
const query = `
SELECT
m.*,
md.keywords,
md.tags,
md.category,
md.context,
md.importance,
md.is_atomic,
md.parent_id
FROM memories m
LEFT JOIN memory_metadata md ON m.id = md.memory_id
WHERE m.id = $1 AND m.user_id = $2
`;
const result = await client.query(query, [memoryId, userId]);
if (result.rows.length === 0) {
return null; // Memory not found or doesn't belong to user
}
const memory = this.rowToMemoryWithMetadata(result.rows[0]);
// Retrieve embeddings
try {
const embeddings = await this.embeddingStorage.retrieveEmbeddings(memoryId);
memory.embeddings = embeddings;
} catch (error) {
// Embeddings are optional, continue without them
Logger.warn(`Failed to retrieve embeddings for memory ${memoryId}:`, error);
}
// Retrieve waypoint links
try {
const linksQuery = `
SELECT source_id, target_id, link_type, weight, created_at, traversal_count
FROM memory_links
WHERE source_id = $1 OR target_id = $1
`;
const linksResult = await client.query(linksQuery, [memoryId]);
memory.links = linksResult.rows.map((row: Record<string, unknown>) => ({
sourceId: row.source_id as string,
targetId: row.target_id as string,
linkType: row.link_type as LinkType,
weight: row.weight as number,
createdAt: new Date(row.created_at as string),
traversalCount: row.traversal_count as number,
}));
} catch (error) {
// Links are optional, continue without them
Logger.warn(`Failed to retrieve links for memory ${memoryId}:`, error);
memory.links = [];
}
return memory;
} catch (error) {
Logger.error("Memory retrieval failed:", error);
if (error instanceof MemoryValidationError) {
throw error;
}
if (error instanceof Error && error.message.includes("Connection")) {
throw new MemoryTransactionError("Database connection failed", "retrieve", error);
}
throw new Error(
`Memory retrieval failed: ${error instanceof Error ? error.message : String(error)}`
);
} finally {
if (client) {
this.db.releaseConnection(client);
}
}
}
/**
* Generate cache key for search query
*/
private generateSearchCacheKey(query: import("./types").SearchQuery): string {
const queryStr = JSON.stringify({
userId: query.userId,
text: query.text,
sectors: query.sectors,
primarySector: query.primarySector,
minStrength: query.minStrength,
minSalience: query.minSalience,
dateRange: query.dateRange,
metadata: query.metadata,
limit: query.limit,
offset: query.offset,
});
return createHash("sha256").update(queryStr).digest("hex");
}
/**
* Get cache metrics (exposed for testing and monitoring)
*/
getCacheMetrics(): { hits: number; misses: number; hitRate: number; size: number } {
return this.searchCache.getMetrics();
}
/**
* Invalidate search cache
*/
private invalidateSearchCache(): void {
this.searchCache.clear();
}
/**
* Search for memories with composite scoring and filtering
*
* Requirements:
* - 2.2: Composite scoring (0.6×similarity + 0.2×salience + 0.1×recency + 0.1×link_weight)
* - 2.3: Multi-sector similarity search
* - 2.4: Filtering and pagination
* - 2.5: Performance (<200ms p95 for 100k memories)
* - 5.1: Use salience-weighted ranking when no text query provided
* - 5.2: Use (0.4×salience + 0.3×recency + 0.3×linkWeight) for non-text queries
* - 5.3: Include ranking method indicator in response
*
* @param query - Search query with filters and pagination
* @returns Search results with ranked memories and scores
*/
async search(query: import("./types").SearchQuery): Promise<import("./types").SearchResult> {
const startTime = Date.now();
// Determine ranking method based on whether text query is provided
const hasTextQuery = !!query.text;
const rankingMethod: import("./types").RankingMethod = hasTextQuery ? "similarity" : "salience";
// Check cache first
const cacheKey = this.generateSearchCacheKey(query);
const cachedResult = this.searchCache.get(cacheKey);
if (cachedResult) {
// Return cached result with updated processing time
return {
...cachedResult,
processingTime: Math.max(1, Date.now() - startTime),
};
}
let client: PoolClient | null = null;
try {
this.validateSearchQuery(query);
const similarityScores = hasTextQuery
? await this.performVectorSearch(query)
: new Map<string, number>();
const { sql, params } = this.buildFilterQuery(query, similarityScores);
client = await this.db.getConnection();
const result = await client.query(sql, params);
const rows = result.rows;
const { memories, scores } = await this.processSearchResults(
rows,
query,
similarityScores,
hasTextQuery
);
const paginatedMemories = this.applyPagination(memories, scores, query);
const processingTime = Math.max(1, Date.now() - startTime);
const searchResult: import("./types").SearchResult = {
memories: paginatedMemories,
totalCount: memories.length,
scores,
processingTime,
rankingMethod,
};
// Cache the result
this.searchCache.set(cacheKey, searchResult);
return searchResult;
} catch (error) {
return this.handleSearchError(error);
} finally {
if (client) {
this.db.releaseConnection(client);
}
}
}
/**
* Process search results by calculating scores and filtering
*
* Requirements:
* - 5.1: Use salience-weighted ranking when no text query provided
* - 5.2: Use appropriate scoring formula based on query type
*/
private async processSearchResults(
rows: Record<string, unknown>[],
query: import("./types").SearchQuery,
similarityScores: Map<string, number>,
hasTextQuery: boolean
): Promise<{ memories: Memory[]; scores: Map<string, import("./types").CompositeScore> }> {
const scores = new Map<string, import("./types").CompositeScore>();
const memories: Memory[] = [];
const memoryIds = rows.map((row) => row.id as string);
const linkWeights = await this.calculateLinkWeights(memoryIds);
for (const row of rows) {
const memory = this.rowToMemoryWithMetadata(row);
if (!this.matchesFilters(memory, query, similarityScores)) {
continue;
}
const similarity = similarityScores.get(memory.id) ?? 0;
const recency = this.calculateRecencyScore(memory.createdAt);
const linkWeight = linkWeights.get(memory.id) ?? 0;
const compositeScore = this.calculateCompositeScore(
similarity,
memory.salience,
recency,
linkWeight,
hasTextQuery
);
scores.set(memory.id, compositeScore);
memories.push(memory);
}
return { memories, scores };
}
/**
* Apply pagination to sorted memories
*/
private applyPagination(
memories: Memory[],
scores: Map<string, import("./types").CompositeScore>,
query: import("./types").SearchQuery
): Memory[] {
memories.sort((a, b) => {
const scoreA = scores.get(a.id)?.total ?? 0;
const scoreB = scores.get(b.id)?.total ?? 0;
return scoreB - scoreA;
});
const limit = Math.min(query.limit ?? 10, 100);
const offset = query.offset ?? 0;
return memories.slice(offset, offset + limit);
}
/**
* Handle search errors with appropriate error types
*/
private handleSearchError(error: unknown): never {
Logger.error("Memory search failed:", error);
if (error instanceof MemoryValidationError) {
throw error;
}
if (error instanceof Error && error.message.includes("Embedding")) {
throw new MemoryCreationError("Embedding generation failed", error);
}
if (error instanceof Error && error.message.includes("Connection")) {
throw new MemoryTransactionError("Database connection failed", "search", error);
}
throw new Error(
`Memory search failed: ${error instanceof Error ? error.message : String(error)}`
);
}
/**
* Perform vector similarity search across sectors
*
* @param query - Search query with optional minSimilarity threshold
* @returns Map of memory IDs to similarity scores
*
* The minSimilarity parameter controls how strict the relevance matching is:
* - 0.7+ : High relevance - only very similar memories (recommended for focused queries)
* - 0.5 (default): Moderate relevance - balanced results
* - 0.3 : Low relevance - broader results, may include tangentially related memories
*/
private async performVectorSearch(
query: import("./types").SearchQuery
): Promise<Map<string, number>> {
if (!query.text) {
return new Map();
}
// Generate query embedding (use semantic for general queries)
// Let embedding errors propagate - don't catch them here
const queryEmbedding = await this.embeddingEngine.generateSemanticEmbedding(query.text);
// Determine sectors to search
const sectors = query.sectors ?? [
"episodic",
"semantic",
"procedural",
"emotional",
"reflective",
];
// Search each sector with configurable similarity threshold
const similarityMap = new Map<string, number>();
const limit = (query.limit ?? 10) * 2; // Get more candidates for filtering
const minSimilarity = query.minSimilarity ?? 0.5; // Default threshold
for (const sector of sectors) {
const results = await this.embeddingStorage.vectorSimilaritySearch(
queryEmbedding,
sector as import("../embeddings/types").MemorySector,
limit,
minSimilarity
);
// Merge results, keeping highest similarity per memory
for (const result of results) {
const existing = similarityMap.get(result.memoryId);
if (!existing || result.similarity > existing) {
similarityMap.set(result.memoryId, result.similarity);
}
}
}
return similarityMap;
}
/**
* Build dynamic filter query
* Properly combines similarity search results with other filters using AND logic
*/
private buildFilterQuery(
query: import("./types").SearchQuery,
similarityScores: Map<string, number>
): { sql: string; params: unknown[] } {
const conditions: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
// Add all filter conditions using helper methods
// userId filter is always required for security
paramIndex = this.addUserIdFilter(conditions, params, paramIndex, query);
// If we have similarity scores from vector search, filter to those IDs
// This is combined with other filters using AND logic
if (similarityScores.size > 0) {
paramIndex = this.addSimilarityFilter(conditions, params, paramIndex, similarityScores);
}
// Add all other filters - these will be combined with AND logic
paramIndex = this.addSectorFilter(conditions, params, paramIndex, query);
paramIndex = this.addStrengthFilter(conditions, params, paramIndex, query);
paramIndex = this.addSalienceFilter(conditions, params, paramIndex, query);
paramIndex = this.addDateRangeFilter(conditions, params, paramIndex, query);
this.addMetadataFilters(conditions, params, paramIndex, query);
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
const sql = `
SELECT
m.*,
md.keywords,
md.tags,
md.category,
md.context,
md.importance,
md.is_atomic,
md.parent_id
FROM memories m
LEFT JOIN memory_metadata md ON m.id = md.memory_id
${whereClause}
ORDER BY m.created_at DESC
`;
return { sql, params };
}
/**
* Add userId filter (always required for security)
*/
private addUserIdFilter(
conditions: string[],
params: unknown[],
paramIndex: number,
query: import("./types").SearchQuery
): number {
conditions.push(`m.user_id = $${paramIndex}`);
params.push(query.userId);
return paramIndex + 1;
}
/**
* Add similarity filter if similarity scores available
*/
private addSimilarityFilter(
conditions: string[],
params: unknown[],
paramIndex: number,
similarityScores: Map<string, number>
): number {
if (similarityScores.size > 0) {
const memoryIds = Array.from(similarityScores.keys());
conditions.push(`m.id = ANY($${paramIndex})`);
params.push(memoryIds);
return paramIndex + 1;
}
return paramIndex;
}
/**
* Add primary sector filter
*/
private addSectorFilter(
conditions: string[],
params: unknown[],
paramIndex: number,
query: import("./types").SearchQuery
): number {
if (query.primarySector) {
conditions.push(`m.primary_sector = $${paramIndex}`);
params.push(query.primarySector);
return paramIndex + 1;
}
return paramIndex;
}
/**
* Add minimum strength filter
*/
private addStrengthFilter(
conditions: string[],
params: unknown[],
paramIndex: number,
query: import("./types").SearchQuery
): number {
// Always filter out soft-deleted memories (strength=0) unless explicitly requesting them
// Use provided minStrength or default to 0.01 to exclude strength=0
const effectiveMinStrength = query.minStrength ?? 0.01;
conditions.push(`m.strength >= $${paramIndex}`);
params.push(effectiveMinStrength);
return paramIndex + 1;
}
/**
* Add minimum salience filter
*/
private addSalienceFilter(
conditions: string[],
params: unknown[],
paramIndex: number,
query: import("./types").SearchQuery
): number {
if (query.minSalience !== undefined) {
conditions.push(`m.salience >= $${paramIndex}`);
params.push(query.minSalience);
return paramIndex + 1;
}
return paramIndex;
}
/**
* Add date range filter
*/
private addDateRangeFilter(
conditions: string[],
params: unknown[],
paramIndex: number,
query: import("./types").SearchQuery
): number {
if (query.dateRange) {
if (query.dateRange.start) {
conditions.push(`m.created_at >= $${paramIndex}`);
params.push(query.dateRange.start);
paramIndex++;
}
if (query.dateRange.end) {
conditions.push(`m.created_at <= $${paramIndex}`);
params.push(query.dateRange.end);
paramIndex++;
}
}
return paramIndex;
}
/**
* Add metadata filters (keywords, tags, category)
*/
private addMetadataFilters(
conditions: string[],
params: unknown[],
paramIndex: number,
query: import("./types").SearchQuery
): number {
// Filter by metadata keywords (array overlap)
if (query.metadata?.keywords && query.metadata.keywords.length > 0) {
conditions.push(`md.keywords && $${paramIndex}`);
params.push(query.metadata.keywords);
paramIndex++;
}
// Filter by metadata tags (array overlap)
if (query.metadata?.tags && query.metadata.tags.length > 0) {
conditions.push(`md.tags && $${paramIndex}`);
params.push(query.metadata.tags);
paramIndex++;
}
// Filter by metadata category
if (query.metadata?.category) {
conditions.push(`md.category = $${paramIndex}`);
params.push(query.metadata.category);
paramIndex++;
}
return paramIndex;
}
/**
* Check if memory matches all filters (client-side validation)
* This provides a safety net for mock databases and ensures correctness
*/
private matchesFilters(
memory: Memory,
query: import("./types").SearchQuery,
similarityScores: Map<string, number>
): boolean {
if (memory.userId !== query.userId) {
return false;
}
if (similarityScores.size > 0 && !similarityScores.has(memory.id)) {
return false;
}
if (query.primarySector && memory.primarySector !== query.primarySector) {
return false;
}
if (!this.matchesStrengthSalience(memory, query)) {
return false;
}
if (!this.matchesDateRange(memory, query)) {
return false;
}
if (!this.matchesMetadataFilters(memory, query)) {
return false;
}
return true;
}
/**
* Check if memory matches strength and salience filters
* By default, excludes soft-deleted memories (strength=0)
*/
private matchesStrengthSalience(memory: Memory, query: import("./types").SearchQuery): boolean {
// Always exclude soft-deleted memories (strength=0) unless explicitly requested
// This ensures soft-deleted memories don't appear in normal retrieval
const effectiveMinStrength = query.minStrength ?? 0.01; // Default: exclude strength=0
if (memory.strength < effectiveMinStrength) {
return false;
}
if (query.minSalience !== undefined && memory.salience < query.minSalience) {
return false;
}
return true;
}
/**
* Check if memory matches date range filter
*/
private matchesDateRange(memory: Memory, query: import("./types").SearchQuery): boolean {
if (!query.dateRange) {
return true;
}
if (query.dateRange.start && memory.createdAt < query.dateRange.start) {
return false;
}
if (query.dateRange.end && memory.createdAt > query.dateRange.end) {
return false;
}
return true;
}
/**
* Check if memory matches metadata filters
*/
private matchesMetadataFilters(memory: Memory, query: import("./types").SearchQuery): boolean {
if (!query.metadata) {
return true;
}
if (query.metadata.keywords && query.metadata.keywords.length > 0) {
const hasKeyword = memory.metadata.keywords?.some(
(k) => query.metadata?.keywords?.includes(k) ?? false
);
if (!hasKeyword) {
return false;
}
}
if (query.metadata.tags && query.metadata.tags.length > 0) {
const hasTag = memory.metadata.tags?.some((t) => query.metadata?.tags?.includes(t) ?? false);
if (!hasTag) {
return false;
}
}
if (query.metadata.category && memory.metadata.category !== query.metadata.category) {
return false;
}
return true;
}
/**
* Calculate recency score (0-1, newer = higher)
* 1 year old = 0.0, brand new = 1.0
*/
private calculateRecencyScore(createdAt: Date): number {
const ageMs = Date.now() - createdAt.getTime();
const oneYearMs = 365 * 24 * 60 * 60 * 1000;
const recency = Math.max(0, 1.0 - ageMs / oneYearMs);
return Math.min(1.0, recency);
}
/**
* Calculate link weights for memories
*
* Considers both source and target links since waypoint connections are bidirectional.
* A memory can be connected as either source_id or target_id in the memory_links table.
*
* Requirements: 14.4 - linkWeight SHALL reflect actual link connections
*/
private async calculateLinkWeights(memoryIds: string[]): Promise<Map<string, number>> {
if (memoryIds.length === 0) {
return new Map();
}
const client = await this.db.getConnection();
try {
// Query both source and target links since connections are bidirectional
// This ensures linkWeight reflects all actual connections for each memory
const query = `
SELECT
memory_id,
AVG(weight) as avg_weight
FROM (
SELECT source_id as memory_id, weight FROM memory_links WHERE source_id = ANY($1)
UNION ALL
SELECT target_id as memory_id, weight FROM memory_links WHERE target_id = ANY($1)
) combined
GROUP BY memory_id
`;
const result = await client.query(query, [memoryIds]);
const linkWeights = new Map<string, number>();
for (const row of result.rows) {
linkWeights.set(row.memory_id as string, (row.avg_weight as number) ?? 0);
}
return linkWeights;
} catch {
// Link weight calculation is non-critical
return new Map();
} finally {
this.db.releaseConnection(client);
}
}
/**
* Calculate composite score with weights
*
* When hasTextQuery is true (similarity-based ranking):
* Formula: 0.6×similarity + 0.2×salience + 0.1×recency + 0.1×linkWeight
*
* When hasTextQuery is false (salience-based ranking):
* Formula: 0.4×salience + 0.3×recency + 0.3×linkWeight
*
* Requirements:
* - 5.1: Use salience-weighted ranking when no text query provided
* - 5.2: Use (0.4×salience + 0.3×recency + 0.3×linkWeight) for non-text queries
*/
private calculateCompositeScore(
similarity: number,
salience: number,
recency: number,
linkWeight: number,
hasTextQuery: boolean = true
): import("./types").CompositeScore {
let total: number;
if (hasTextQuery) {
// Similarity-based scoring: 0.6×similarity + 0.2×salience + 0.1×recency + 0.1×linkWeight
total = 0.6 * similarity + 0.2 * salience + 0.1 * recency + 0.1 * linkWeight;
} else {
// Salience-based scoring: 0.4×salience + 0.3×recency + 0.3×linkWeight
total = 0.4 * salience + 0.3 * recency + 0.3 * linkWeight;
}
return {
total: Math.max(0, Math.min(1.0, total)),
similarity: Math.max(0, Math.min(1.0, similarity)),
salience: Math.max(0, Math.min(1.0, salience)),
recency: Math.max(0, Math.min(1.0, recency)),
linkWeight: Math.max(0, Math.min(1.0, linkWeight)),
};
}
/**
* Convert database row to Memory object with metadata
*/
private rowToMemoryWithMetadata(row: Record<string, unknown>): Memory {
return {
id: row.id as string,
content: row.content as string,
createdAt: new Date(row.created_at as string),
lastAccessed: new Date(row.last_accessed as string),
accessCount: row.access_count as number,
salience: row.salience as number,
decayRate: row.decay_rate as number,
strength: row.strength as number,
userId: row.user_id as string,
sessionId: row.session_id as string,
primarySector: row.primary_sector as MemorySectorType,
metadata: {
keywords: (row.keywords as string[]) ?? [],
tags: (row.tags as string[]) ?? [],
category: (row.category as string) ?? "",
context: (row.context as string) ?? "",
importance: (row.importance as number) ?? 0.5,
isAtomic: (row.is_atomic as boolean) ?? true,
parentId: (row.parent_id as string) ?? undefined,
},
};
}
/**
* Validate userId is provided
*/
private validateUserId(userId: string | undefined): void {
if (!userId || userId.trim().length === 0) {
throw new MemoryValidationError("userId is required", "userId", userId);
}
}
/**
* Validate strength range (0-1)
*/
private validateStrengthRange(strength: number | undefined, fieldName: string): void {
if (strength !== undefined) {
if (strength < 0 || strength > 1) {
// Capitalize first letter for error message
const displayName = fieldName.charAt(0).toUpperCase() + fieldName.slice(1);
throw new MemoryValidationError(
`${displayName} must be between 0 and 1`,
fieldName,
strength
);
}
}
}
/**
* Validate salience range (0-1)
*/
private validateSalienceRange(salience: number | undefined): void {
if (salience !== undefined) {
if (salience < 0 || salience > 1) {
throw new MemoryValidationError(
"minSalience must be between 0 and 1",
"minSalience",
salience
);
}
}
}
/**
* Validate date range
*/
private validateDateRange(dateRange: { start?: Date; end?: Date } | undefined): void {
if (dateRange) {
if (dateRange.start && dateRange.end) {
if (dateRange.end < dateRange.start) {
throw new MemoryValidationError(
"dateRange.end must be >= dateRange.start",
"dateRange",
dateRange
);
}
}
}
}
/**
* Validate sectors array
*/
private validateSectors(sectors: string[] | undefined): void {
if (sectors) {
for (const sector of sectors) {
if (!VALID_SECTORS.includes(sector as MemorySectorType)) {
throw new MemoryValidationError("Invalid memory sector", "sectors", sector);
}
}
}
}
/**
* Validate search query parameters
*/
private validateSearchQuery(query: import("./types").SearchQuery): void {
this.validateUserId(query.userId);
this.validateStrengthRange(query.minStrength, "minStrength");
this.validateSalienceRange(query.minSalience);
this.validateDateRange(query.dateRange);
this.validateSectors(query.sectors);
}
/**
* Update an existing memory with selective field updates
*
* Requirements:
* - 2.1: Update memory content and fields
* - 2.2: Automatic embedding regeneration on content change
* - 2.3: Waypoint connection updates
* - 2.4: Metadata updates
* - 2.5: Strength, salience, importance updates
*
* @param updates - Fields to update
* @returns Updated memory with metadata about what changed
*/
async update(
updates: import("./types").UpdateMemoryInput
): Promise<import("./types").UpdateMemoryResult> {
const startTime = Date.now();
// Validate input
this.validateUpdateInput(updates);
let client: PoolClient | undefined;
try {
// Retrieve existing memory first (before transaction)
const retrievalClient = await this.db.getConnection();
let existingMemory: Memory;
try {
existingMemory = await this.retrieveExistingMemory(retrievalClient, updates.memoryId);
} finally {
this.db.releaseConnection(retrievalClient);
}
// Begin transaction
client = await this.db.beginTransaction();
// Verify ownership
if (existingMemory.userId !== updates.userId) {
throw new MemoryUpdateError("Memory does not belong to user");
}
// Track what changed
let embeddingsRegenerated = false;
let connectionsUpdated = false;
// Update memory record
const updatedMemory = await this.updateMemoryRecord(client, existingMemory, updates);
// Update metadata if provided
if (updates.metadata) {
const mergedMetadata = await this.updateMetadataRecord(
client,
updates.memoryId,
existingMemory,
updates.metadata
);
// Assign merged metadata to the updated memory object
updatedMemory.metadata = mergedMetadata;
}
// Regenerate embeddings if content changed
if (updates.content !== undefined) {
const embeddings = await this.embeddingEngine.generateAllSectorEmbeddings({
text: updates.content,
sector:
existingMemory.primarySector as unknown as import("../embeddings/types").MemorySector,
});
updatedMemory.embeddings = embeddings;
await this.embeddingStorage.storeEmbeddings(updates.memoryId, embeddings, "default");
embeddingsRegenerated = true;
// Update waypoint connections if content changed
await this.deleteOldConnections(client, updates.memoryId);
const links = await this.createWaypointConnections(client, updatedMemory);
// Store updated waypoint connections in database
if (links.length > 0) {
await this.storeWaypointLinks(client, links);
}
updatedMemory.links = links;
connectionsUpdated = true;
}
// Commit transaction
await this.db.commitTransaction(client);
// Invalidate search cache after successful update
this.invalidateSearchCache();
const processingTime = Math.max(1, Date.now() - startTime); // Ensure at least 1ms
return {
memory: updatedMemory,
embeddingsRegenerated,
connectionsUpdated,
processingTime,
};
} catch (error) {
// Rollback on error
if (client) {
await this.db.rollbackTransaction(client);
}
// Re-throw with context
if (error instanceof MemoryValidationError || error instanceof MemoryUpdateError) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes("Connection failed")) {
throw new MemoryTransactionError(
"Database connection failed",
"begin_transaction",
error as Error
);
}
if (errorMessage.includes("Embedding")) {
throw new MemoryCreationError("Embedding generation failed", error as Error);
}
throw new MemoryUpdateError("Memory update failed", error as Error);
}
}
/**
* Validate memoryId is provided
*/
private validateMemoryId(memoryId: string | undefined): void {
if (!memoryId || memoryId.trim().length === 0) {
throw new MemoryValidationError("memoryId is required", "memoryId", memoryId);
}
}
/**
* Validate at least one update field is provided
*/
private validateHasUpdates(updates: import("./types").UpdateMemoryInput): void {
const hasUpdates =
updates.content !== undefined ||
updates.strength !== undefined ||
updates.salience !== undefined ||
updates.metadata !== undefined;
if (!hasUpdates) {
throw new MemoryValidationError("No fields to update", "updates", updates);
}
}
/**
* Validate content field
*/
private validateContentField(content: string | undefined): void {
if (content !== undefined) {
if (content.trim().length === 0) {
throw new MemoryValidationError("Content cannot be empty", "content", content);
}
}
}
/**
* Validate metadata importance field
*/
private validateMetadataImportance(metadata: { importance?: number } | undefined): void {
if (metadata?.importance !== undefined) {
if (metadata.importance < 0 || metadata.importance > 1) {
throw new MemoryValidationError(
"Importance must be between 0 and 1",
"importance",
metadata.importance
);
}
}
}
/**
* Validate update input
*/
private validateUpdateInput(updates: import("./types").UpdateMemoryInput): void {
this.validateMemoryId(updates.memoryId);
this.validateUserId(updates.userId);
this.validateHasUpdates(updates);
this.validateContentField(updates.content);
this.validateStrengthRange(updates.strength, "strength");
this.validateStrengthRange(updates.salience, "salience");
this.validateMetadataImportance(updates.metadata);
}
/**
* Retrieve existing memory from database
*/
private async retrieveExistingMemory(client: PoolClient, memoryId: string): Promise<Memory> {
const query = `
SELECT
m.*,
md.keywords,
md.tags,
md.category,
md.context,
md.importance,
md.is_atomic,
md.parent_id
FROM memories m
LEFT JOIN memory_metadata md ON m.id = md.memory_id
WHERE m.id = $1
`;
const result = await client.query(query, [memoryId]);
if (result.rows.length === 0) {
throw new MemoryUpdateError("Memory not found");
}
return this.rowToMemoryWithMetadata(result.rows[0]);
}
/**
* Update memory record in database
*/
private async updateMemoryRecord(
client: PoolClient,
existingMemory: Memory,
updates: import("./types").UpdateMemoryInput
): Promise<Memory> {
const now = new Date();
// Build update fields
const fields: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
// Always update lastAccessed
fields.push(`last_accessed = $${paramIndex++}`);
values.push(now);
// Update content if provided
if (updates.content !== undefined) {
fields.push(`content = $${paramIndex++}`);
values.push(updates.content);
}
// Update strength if provided
if (updates.strength !== undefined) {
fields.push(`strength = $${paramIndex++}`);
values.push(updates.strength);
}
// Update salience if provided
if (updates.salience !== undefined) {
fields.push(`salience = $${paramIndex++}`);
values.push(updates.salience);
}
// Add memoryId as last parameter
values.push(existingMemory.id);
const query = `
UPDATE memories
SET ${fields.join(", ")}
WHERE id = $${paramIndex}
RETURNING *
`;
await client.query(query, values);
// Build updated memory object
const updatedMemory: Memory = {
...existingMemory,
content: updates.content ?? existingMemory.content,
strength: updates.strength ?? existingMemory.strength,
salience: updates.salience ?? existingMemory.salience,
lastAccessed: now,
};
return updatedMemory;
}
/**
* Update metadata record in database using MetadataMerger
*
* Requirements:
* - 9.1: Partial update merges with existing metadata
* - 9.2: Existing fields are replaced with new values
* - 9.3: New fields are added to existing metadata
* - 9.4: Fields set to null are removed
* - 9.5: Empty update preserves existing metadata
*
* @returns The merged metadata object to be assigned to the updated memory
*/
private async updateMetadataRecord(
client: PoolClient,
memoryId: string,
existingMemory: Memory,
metadata: MetadataUpdate
): Promise<MemoryMetadata> {
// Use MetadataMerger to merge update with existing metadata
const mergeResult = this.metadataMerger.merge(existingMemory.metadata, metadata);
// Check if there are any changes
if (
mergeResult.updatedFields.length === 0 &&
mergeResult.removedFields.length === 0 &&
mergeResult.addedFields.length === 0
) {
return existingMemory.metadata; // No changes to apply (Requirement 9.5)
}
// Build the update query with all merged fields
// We need to update all fields to ensure removed fields are set to NULL
const fields: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
// Add all metadata fields from merged result
// For removed fields, we explicitly set them to NULL
// Create query parts object for cleaner parameter passing
const queryParts = { fields, values, paramIndex };
paramIndex = this.addMetadataFieldUpdateWithNull(queryParts, {
dbColumnName: "keywords",
value: mergeResult.merged.keywords,
isRemoved: mergeResult.removedFields.includes("keywords"),
});
queryParts.paramIndex = paramIndex;
paramIndex = this.addMetadataFieldUpdateWithNull(queryParts, {
dbColumnName: "tags",
value: mergeResult.merged.tags,
isRemoved: mergeResult.removedFields.includes("tags"),
});
queryParts.paramIndex = paramIndex;
paramIndex = this.addMetadataFieldUpdateWithNull(queryParts, {
dbColumnName: "category",
value: mergeResult.merged.category,
isRemoved: mergeResult.removedFields.includes("category"),
});
queryParts.paramIndex = paramIndex;
paramIndex = this.addMetadataFieldUpdateWithNull(queryParts, {
dbColumnName: "context",
value: mergeResult.merged.context,
isRemoved: mergeResult.removedFields.includes("context"),
});
queryParts.paramIndex = paramIndex;
paramIndex = this.addMetadataFieldUpdateWithNull(queryParts, {
dbColumnName: "importance",
value: mergeResult.merged.importance,
isRemoved: mergeResult.removedFields.includes("importance"),
});
queryParts.paramIndex = paramIndex;
paramIndex = this.addMetadataFieldUpdateWithNull(queryParts, {
dbColumnName: "is_atomic",
value: mergeResult.merged.isAtomic,
isRemoved: mergeResult.removedFields.includes("isAtomic"),
});
queryParts.paramIndex = paramIndex;
paramIndex = this.addMetadataFieldUpdateWithNull(queryParts, {
dbColumnName: "parent_id",
value: mergeResult.merged.parentId,
isRemoved: mergeResult.removedFields.includes("parentId"),
});
if (fields.length === 0) {
return mergeResult.merged; // No database update needed, but return merged metadata
}
// Add memoryId as last parameter
values.push(memoryId);
const query = `
UPDATE memory_metadata
SET ${fields.join(", ")}
WHERE memory_id = $${paramIndex}
`;
await client.query(query, values);
// Return merged metadata to be assigned to the updated memory
return mergeResult.merged;
}
/**
* Add metadata field to update query, handling null values for removal
*
* @param queryParts - Object containing fields array, values array, and current paramIndex
* @param fieldInfo - Object containing dbColumnName, value, and isRemoved flag
* @returns Next parameter index
*/
private addMetadataFieldUpdateWithNull(
queryParts: { fields: string[]; values: unknown[]; paramIndex: number },
fieldInfo: { dbColumnName: string; value: unknown; isRemoved: boolean }
): number {
const { fields, values, paramIndex } = queryParts;
const { dbColumnName, value, isRemoved } = fieldInfo;
// If field was explicitly removed, set to NULL
if (isRemoved) {
fields.push(`${dbColumnName} = $${paramIndex}`);
values.push(null);
return paramIndex + 1;
}
// If field has a value, update it
if (value !== undefined) {
fields.push(`${dbColumnName} = $${paramIndex}`);
values.push(value);
return paramIndex + 1;
}
return paramIndex;
}
/**
* Delete old waypoint connections
*/
private async deleteOldConnections(client: PoolClient, memoryId: string): Promise<void> {
const query = `
DELETE FROM memory_links
WHERE source_id = $1 OR target_id = $1
`;
await client.query(query, [memoryId]);
}
/**
* Validate batch delete input
*/
private validateBatchDeleteInput(memoryIds: string[]): void {
if (!memoryIds || memoryIds.length === 0) {
throw new MemoryValidationError("Memory IDs array cannot be empty", "memoryIds", memoryIds);
}
for (const memoryId of memoryIds) {
if (!memoryId || memoryId.trim().length === 0) {
throw new MemoryValidationError("Memory ID cannot be empty", "memoryId", memoryId);
}
}
}
/**
* Perform soft delete (set strength to 0)
*/
private async performSoftDelete(
client: PoolClient,
memoryIds: string[]
): Promise<{ successCount: number; failures: Array<{ memoryId: string; error: string }> }> {
const checkQuery = `SELECT id FROM memories WHERE id = ANY($1)`;
const checkResult = await client.query(checkQuery, [memoryIds]);
const existingIds = checkResult.rows.map((row: Record<string, unknown>) => row.id as string);
const failures: Array<{ memoryId: string; error: string }> = [];
for (const memoryId of memoryIds) {
if (!existingIds.includes(memoryId)) {
failures.push({ memoryId, error: "Memory not found" });
}
}
let successCount = 0;
if (existingIds.length > 0) {
const updateQuery = `UPDATE memories SET strength = 0 WHERE id = ANY($1)`;
await client.query(updateQuery, [existingIds]);
successCount = existingIds.length;
}
return { successCount, failures };
}
/**
* Perform hard delete (remove records with cascade)
*/
private async performHardDelete(
client: PoolClient,
memoryIds: string[]
): Promise<{ successCount: number; failures: Array<{ memoryId: string; error: string }> }> {
const checkQuery = `SELECT id FROM memories WHERE id = ANY($1)`;
const checkResult = await client.query(checkQuery, [memoryIds]);
const existingIds = checkResult.rows.map((row: Record<string, unknown>) => row.id as string);
const failures: Array<{ memoryId: string; error: string }> = [];
for (const memoryId of memoryIds) {
if (!existingIds.includes(memoryId)) {
failures.push({ memoryId, error: "Memory not found" });
}
}
let successCount = 0;
if (existingIds.length > 0) {
// Explicitly delete related data to ensure proper cleanup
// regardless of CASCADE constraint state in the database
await client.query(`DELETE FROM memory_links WHERE source_id = ANY($1)`, [existingIds]);
await client.query(`DELETE FROM memory_links WHERE target_id = ANY($1)`, [existingIds]);
await client.query(`DELETE FROM memory_metadata WHERE memory_id = ANY($1)`, [existingIds]);
await client.query(`DELETE FROM memory_embeddings WHERE memory_id = ANY($1)`, [existingIds]);
const deleteQuery = `DELETE FROM memories WHERE id = ANY($1)`;
await client.query(deleteQuery, [existingIds]);
successCount = existingIds.length;
}
return { successCount, failures };
}
/**
* Batch delete multiple memories with cascade deletion or soft delete
*
* Requirements:
* - 2.1: Batch memory deletion with proper error handling
* - 2.2: Cascade deletion of embeddings (hard delete)
* - 2.3: Cascade deletion of waypoint connections (hard delete)
* - 2.4: Cascade deletion of metadata (hard delete)
* - 2.5: Soft delete option (set strength to 0, preserve data)
*
* @param memoryIds - Array of memory IDs to delete
* @param soft - If true, perform soft delete (set strength=0); if false, hard delete (remove records)
* @returns BatchDeleteResult with success/failure counts and details
* @throws MemoryValidationError if input is invalid
* @throws MemoryTransactionError if deletion fails
*/
async batchDelete(
memoryIds: string[],
soft: boolean
): Promise<import("./types").BatchDeleteResult> {
const startTime = Date.now();
// Validate input
this.validateBatchDeleteInput(memoryIds);
let client: PoolClient | undefined;
try {
// Begin transaction for atomic batch deletion
client = await this.db.beginTransaction();
// Perform soft or hard delete
const result = soft
? await this.performSoftDelete(client, memoryIds)
: await this.performHardDelete(client, memoryIds);
// Commit transaction
await this.db.commitTransaction(client);
// Invalidate search cache after successful batch deletion
this.invalidateSearchCache();
const processingTime = Date.now() - startTime;
return {
successCount: result.successCount,
failureCount: result.failures.length,
failures: result.failures,
processingTime,
};
} catch (error) {
// Rollback transaction on error
if (client) {
await this.db.rollbackTransaction(client);
}
// Re-throw validation errors as-is
if (error instanceof MemoryValidationError) {
throw error;
}
// Wrap other errors in transaction error
throw new MemoryTransactionError(
`Failed to batch delete memories`,
"batch_delete",
error as Error
);
}
}
/**
* Delete a memory with cascade deletion or soft delete
*
* Requirements:
* - 2.1: Memory deletion with proper error handling
* - 2.2: Cascade deletion of embeddings (hard delete)
* - 2.3: Cascade deletion of waypoint connections (hard delete)
* - 2.4: Cascade deletion of metadata (hard delete)
* - 2.5: Soft delete option (set strength to 0, preserve data)
*
* @param memoryId - ID of memory to delete
* @param soft - If true, perform soft delete (set strength=0); if false, hard delete (remove record)
* @throws MemoryValidationError if memoryId is invalid
* @throws MemoryTransactionError if deletion fails
*/
async delete(memoryId: string, soft: boolean): Promise<void> {
// Validate input
if (!memoryId || memoryId.trim().length === 0) {
throw new MemoryValidationError("Memory ID cannot be empty", "memoryId", memoryId);
}
let client: PoolClient | undefined;
try {
// Begin transaction for atomic deletion
client = await this.db.beginTransaction();
if (soft) {
// Soft delete: Set strength to 0 but keep all data
const updateQuery = `
UPDATE memories
SET strength = 0
WHERE id = $1
RETURNING id
`;
const result = await client.query(updateQuery, [memoryId]);
if (result.rows.length === 0) {
throw new MemoryValidationError("Memory not found", "memoryId", memoryId);
}
// Soft delete does NOT cascade delete related data
// Embeddings, connections, and metadata are preserved
} else {
// Hard delete: Remove memory record
// First, explicitly delete related data to ensure proper cleanup
// regardless of CASCADE constraint state in the database
// Delete links where this memory is the source (forward links)
await client.query(`DELETE FROM memory_links WHERE source_id = $1`, [memoryId]);
// Delete links where this memory is the target (reverse links)
await client.query(`DELETE FROM memory_links WHERE target_id = $1`, [memoryId]);
// Explicitly delete metadata (don't rely on CASCADE)
await client.query(`DELETE FROM memory_metadata WHERE memory_id = $1`, [memoryId]);
// Explicitly delete embeddings (don't rely on CASCADE)
await client.query(`DELETE FROM memory_embeddings WHERE memory_id = $1`, [memoryId]);
// Now delete the memory record
const deleteQuery = `
DELETE FROM memories
WHERE id = $1
RETURNING id
`;
const result = await client.query(deleteQuery, [memoryId]);
if (result.rows.length === 0) {
throw new MemoryValidationError("Memory not found", "memoryId", memoryId);
}
}
// Commit transaction
await this.db.commitTransaction(client);
// Invalidate search cache after successful deletion
this.invalidateSearchCache();
} catch (error) {
// Rollback transaction on error
if (client) {
await this.db.rollbackTransaction(client);
}
// Re-throw validation errors as-is
if (error instanceof MemoryValidationError) {
throw error;
}
// Wrap other errors in transaction error
throw new MemoryTransactionError(
`Failed to delete memory: ${memoryId}`,
"delete",
error as Error
);
}
}
/**
* Search memories using full-text search
*
* Integrates FullTextSearchEngine with MemoryRepository to provide
* full-text search capabilities with proper error handling and result enrichment.
*
* Requirements:
* - 4.1: Full-text search using PostgreSQL ts_vector
* - 4.2: Boolean operators and phrase matching
* - 4.3: Result ranking and highlighting
* - 4.4: Filtering by userId, strength, salience
* - 4.5: Performance (<200ms p95 for 100k memories)
*
* @param query - Full-text search query parameters
* @returns Search results with statistics
* @throws MemoryValidationError if query parameters are invalid
*/
async searchFullText(query: FullTextSearchQuery): Promise<FullTextSearchResponse> {
try {
// Delegate to FullTextSearchEngine
const result = await this.fullTextSearchEngine.search(query);
// Results are already enriched with memory metadata by the search engine
// No additional enrichment needed
return result;
} catch (error) {
// Re-throw validation errors as-is
if (error instanceof MemoryValidationError) {
throw error;
}
// Wrap other errors for consistency
Logger.error("Full-text search failed:", error);
throw new Error(
`Full-text search failed: ${error instanceof Error ? error.message : String(error)}`
);
}
}
/**
* Create multiple memories in a batch operation
*
* Processes memories sequentially to ensure proper embedding generation
* and waypoint connections. Returns results for all memories, including
* any that failed.
*
* @param input - Batch creation input with memories array
* @returns Batch creation result with success/failure counts
*/
async batchCreate(
input: import("./types").BatchCreateInput
): Promise<import("./types").BatchCreateResult> {
const startTime = Date.now();
const created: Array<{ memoryId: string; content: string }> = [];
const failures: Array<{ content: string; error: string }> = [];
for (const memoryInput of input.memories) {
try {
const memory = await this.create(
{
content: memoryInput.content,
userId: input.userId,
sessionId: input.sessionId,
primarySector: memoryInput.primarySector,
},
memoryInput.metadata
);
created.push({
memoryId: memory.id,
content: memoryInput.content.substring(0, 100),
});
} catch (error) {
failures.push({
content: memoryInput.content.substring(0, 100),
error: error instanceof Error ? error.message : String(error),
});
}
}
return {
successCount: created.length,
failureCount: failures.length,
created,
failures,
processingTime: Date.now() - startTime,
};
}
/**
* Retrieve multiple memories by IDs in a batch operation
*
* Efficiently retrieves multiple memories in a single database query.
* Returns found memories and lists IDs that were not found.
* By default, excludes soft-deleted memories (strength=0).
*
* Requirements:
* - 2.1: Return current strength value for each memory
* - 2.3: Exclude soft-deleted memories by default
* - 2.4: Include soft-deleted memories when includeDeleted=true
*
* @param input - Batch retrieval input with memory IDs and optional includeDeleted flag
* @returns Batch retrieval result with memories and not found IDs
*/
async batchRetrieve(
input: import("./types").BatchRetrieveInput
): Promise<import("./types").BatchRetrieveResult> {
const startTime = Date.now();
let client: import("pg").PoolClient | null = null;
try {
client = await this.db.getConnection();
// Build query with optional soft-delete filter
// By default (includeDeleted=false/undefined), exclude soft-deleted memories (strength=0)
const includeDeleted = input.includeDeleted ?? false;
const strengthFilter = includeDeleted ? "" : "AND m.strength > 0";
const query = `
SELECT
m.*,
md.keywords,
md.tags,
md.category,
md.context,
md.importance,
md.is_atomic,
md.parent_id
FROM memories m
LEFT JOIN memory_metadata md ON m.id = md.memory_id
WHERE m.id = ANY($1) AND m.user_id = $2 ${strengthFilter}
`;
const result = await client.query(query, [input.memoryIds, input.userId]);
const memories: Memory[] = result.rows.map((row: Record<string, unknown>) =>
this.rowToMemoryWithMetadata(row)
);
// Find IDs that were not found
const foundIds = new Set(memories.map((m) => m.id));
const notFound = input.memoryIds.filter((id) => !foundIds.has(id));
return {
memories,
notFound,
processingTime: Date.now() - startTime,
};
} catch (error) {
Logger.error("Batch retrieval failed:", error);
throw new Error(
`Batch retrieval failed: ${error instanceof Error ? error.message : String(error)}`
);
} finally {
if (client) {
this.db.releaseConnection(client);
}
}
}
}