import { getPool, initializeDatabase, isDbInitialized } from '../db/index.js';
import { generateEmbedding, prepareContentForEmbedding } from './embedding.js';
import { sendLogMessage } from '../utils/logger.js';
import { CONFIG } from '../config.js';
export const memoryService = {
async create(toolArgs) {
if (!isDbInitialized()) await initializeDatabase();
sendLogMessage('info', 'Creating new memory', { type: toolArgs.type });
const { type, content, source, tags = [], confidence = 1.0 } = toolArgs;
const textForEmbedding = prepareContentForEmbedding(content);
const embedding = await generateEmbedding(textForEmbedding);
const query = `
INSERT INTO memories (type, content, source, embedding, tags, confidence)
VALUES ($1, $2::jsonb, $3, $4::vector, $5, $6)
RETURNING *
`;
const dbResult = await getPool().query(query, [
type,
JSON.stringify(content),
source,
`[${embedding.join(',')}]`,
tags,
confidence
]);
sendLogMessage('info', 'Memory created successfully', { id: dbResult.rows[0].id });
return dbResult.rows[0];
},
async search(toolArgs) {
if (!isDbInitialized()) await initializeDatabase();
const { query, type, tags, limit = CONFIG.search.defaultLimit } = toolArgs;
sendLogMessage('info', 'Searching memories', { query, type, tags, limit });
sendLogMessage('debug', 'Generating embedding for search query');
const embedding = await generateEmbedding(query);
sendLogMessage('debug', 'Embedding generated', { dimensions: embedding.length });
// Simple similarity search (weighted_score calculated in JS if needed)
let sqlQuery = `
SELECT m.*,
1 - (embedding <=> $1::vector) as similarity
FROM memories m
WHERE 1=1
`;
const queryParams = [`[${embedding.join(',')}]`];
let paramCount = 1;
if (type) {
paramCount++;
sqlQuery += ` AND type = $${paramCount}`;
queryParams.push(type);
}
if (tags && tags.length > 0) {
paramCount++;
sqlQuery += ` AND tags @> $${paramCount}`;
queryParams.push(tags);
}
sqlQuery += ` ORDER BY embedding <=> $1::vector LIMIT $${paramCount + 1}`;
queryParams.push(limit);
sendLogMessage('debug', 'Executing search query', { paramCount: queryParams.length });
const dbResult = await getPool().query(sqlQuery, queryParams);
sendLogMessage('debug', 'Search query completed', { rowCount: dbResult.rows.length });
// Update access stats for returned memories (async, don't wait)
if (dbResult.rows.length > 0) {
const memoryIds = dbResult.rows.map(r => r.id);
this._updateAccessStats(memoryIds).catch(err => {
sendLogMessage('warn', 'Failed to update access stats', { error: err.message });
});
}
return dbResult.rows;
},
// Internal: Update access statistics for memories
async _updateAccessStats(memoryIds) {
if (!memoryIds || memoryIds.length === 0) return;
const pool = getPool();
await pool.query(`
UPDATE memories
SET access_count = COALESCE(access_count, 0) + 1,
last_accessed_at = CURRENT_TIMESTAMP
WHERE id = ANY($1::uuid[])
`, [memoryIds]);
sendLogMessage('debug', 'Updated access stats', { count: memoryIds.length });
},
async list(toolArgs) {
if (!isDbInitialized()) await initializeDatabase();
const { type, tags, limit = 50 } = toolArgs || {};
let sqlQuery = 'SELECT * FROM memories WHERE 1=1';
const queryParams = [];
let paramCount = 0;
if (type) {
paramCount++;
sqlQuery += ` AND type = $${paramCount}`;
queryParams.push(type);
}
if (tags && tags.length > 0) {
paramCount++;
sqlQuery += ` AND tags @> $${paramCount}`;
queryParams.push(tags);
}
sqlQuery += ` ORDER BY created_at DESC LIMIT $${paramCount + 1}`;
queryParams.push(limit);
const dbResult = await getPool().query(sqlQuery, queryParams);
return dbResult.rows;
},
async update(toolArgs) {
if (!isDbInitialized()) await initializeDatabase();
const { id, content, tags, confidence, type, source } = toolArgs;
// Build update query dynamically
let updateParts = [];
const queryParams = [id];
let paramCount = 1;
if (content) {
paramCount++;
updateParts.push(`content = $${paramCount}::jsonb`);
queryParams.push(JSON.stringify(content));
// If content changes, update embedding
const textForEmbedding = prepareContentForEmbedding(content);
const embedding = await generateEmbedding(textForEmbedding);
paramCount++;
updateParts.push(`embedding = $${paramCount}::vector`);
queryParams.push(`[${embedding.join(',')}]`);
}
if (tags) {
paramCount++;
updateParts.push(`tags = $${paramCount}`);
queryParams.push(tags);
}
if (confidence !== undefined) {
paramCount++;
updateParts.push(`confidence = $${paramCount}`);
queryParams.push(confidence);
}
if (type) {
paramCount++;
updateParts.push(`type = $${paramCount}`);
queryParams.push(type);
}
if (source) {
paramCount++;
updateParts.push(`source = $${paramCount}`);
queryParams.push(source);
}
if (updateParts.length === 0) {
throw new Error("No fields to update");
}
const sqlQuery = `
UPDATE memories
SET ${updateParts.join(', ')}
WHERE id = $1
RETURNING *
`;
const dbResult = await getPool().query(sqlQuery, queryParams);
if (dbResult.rows.length === 0) {
throw new Error(`Memory not found: ${id}`);
}
return dbResult.rows[0];
},
async delete(toolArgs) {
if (!isDbInitialized()) await initializeDatabase();
const { id } = toolArgs;
const dbResult = await getPool().query('DELETE FROM memories WHERE id = $1 RETURNING id', [id]);
if (dbResult.rows.length === 0) {
throw new Error(`Memory not found: ${id}`);
}
return { deleted: true, id };
},
// Hybrid search: combines vector similarity with keyword matching
async hybridSearch(toolArgs) {
if (!isDbInitialized()) await initializeDatabase();
const { query, type, tags, limit = CONFIG.search.defaultLimit, vectorWeight = 0.7 } = toolArgs;
sendLogMessage('info', 'Hybrid searching memories', { query, type, tags, limit, vectorWeight });
const embedding = await generateEmbedding(query);
const keywords = query.toLowerCase().split(/\s+/).filter(k => k.length > 2);
const keywordPatterns = keywords.map(k => `%${k}%`);
const pool = getPool();
// Build the hybrid query
let sqlQuery = `
WITH vector_results AS (
SELECT id, content, type, source, tags, confidence, created_at, updated_at,
access_count, last_accessed_at,
1 - (embedding <=> $1::vector) as vector_score
FROM memories
WHERE 1=1
${type ? 'AND type = $3' : ''}
${tags && tags.length > 0 ? `AND tags @> $${type ? 4 : 3}` : ''}
ORDER BY embedding <=> $1::vector
LIMIT $2
),
keyword_results AS (
SELECT id, content, type, source, tags, confidence, created_at, updated_at,
access_count, last_accessed_at,
1.0 as keyword_score
FROM memories
WHERE content::text ILIKE ANY($${type && tags && tags.length > 0 ? 5 : type || (tags && tags.length > 0) ? 4 : 3}::text[])
${type ? `AND type = $${type && tags && tags.length > 0 ? 3 : 3}` : ''}
LIMIT $2
)
SELECT DISTINCT ON (COALESCE(v.id, k.id))
COALESCE(v.id, k.id) as id,
COALESCE(v.content, k.content) as content,
COALESCE(v.type, k.type) as type,
COALESCE(v.source, k.source) as source,
COALESCE(v.tags, k.tags) as tags,
COALESCE(v.confidence, k.confidence) as confidence,
COALESCE(v.created_at, k.created_at) as created_at,
COALESCE(v.updated_at, k.updated_at) as updated_at,
COALESCE(v.access_count, k.access_count) as access_count,
COALESCE(v.last_accessed_at, k.last_accessed_at) as last_accessed_at,
COALESCE(v.vector_score, 0) as vector_score,
COALESCE(k.keyword_score, 0) as keyword_score,
COALESCE(v.vector_score, 0) * $${type && tags && tags.length > 0 ? 6 : type || (tags && tags.length > 0) ? 5 : 4}::float
+ COALESCE(k.keyword_score, 0) * (1 - $${type && tags && tags.length > 0 ? 6 : type || (tags && tags.length > 0) ? 5 : 4}::float) as final_score
FROM vector_results v
FULL OUTER JOIN keyword_results k ON v.id = k.id
ORDER BY COALESCE(v.id, k.id), final_score DESC
`;
// Simplified version for now - just use vector search with keyword boost
const simpleQuery = `
SELECT m.*,
1 - (embedding <=> $1::vector) as similarity,
CASE
WHEN content::text ILIKE ANY($3::text[]) THEN 0.3
ELSE 0
END as keyword_boost
FROM memories m
WHERE 1=1
${type ? 'AND type = $4' : ''}
${tags && tags.length > 0 ? `AND tags @> $${type ? 5 : 4}` : ''}
ORDER BY (1 - (embedding <=> $1::vector)) * $${type && tags && tags.length > 0 ? 6 : type ? 5 : tags && tags.length > 0 ? 5 : 4}::float
+ CASE WHEN content::text ILIKE ANY($3::text[]) THEN 0.3 ELSE 0 END DESC
LIMIT $2
`;
const queryParams = [`[${embedding.join(',')}]`, limit, keywordPatterns];
if (type) queryParams.push(type);
if (tags && tags.length > 0) queryParams.push(tags);
queryParams.push(vectorWeight);
const dbResult = await pool.query(simpleQuery, queryParams);
// Update access stats
if (dbResult.rows.length > 0) {
const memoryIds = dbResult.rows.map(r => r.id);
this._updateAccessStats(memoryIds).catch(err => {
sendLogMessage('warn', 'Failed to update access stats', { error: err.message });
});
}
sendLogMessage('debug', 'Hybrid search completed', {
rowCount: dbResult.rows.length,
keywords: keywords
});
return dbResult.rows;
}
};