SHA-256 hash check before embedding API call eliminates ~60-80% of redundant embedding requests. Consolidates dual INSERT paths to single INSERT with nullable embedding column. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
285 lines
8.0 KiB
TypeScript
285 lines
8.0 KiB
TypeScript
// Session memory operations for persistent learnings
|
|
|
|
import { query, queryOne, execute } from '../db.js';
|
|
import { getEmbedding, formatEmbedding, generateContentHash } from '../embeddings.js';
|
|
|
|
type MemoryCategory = 'pattern' | 'fix' | 'preference' | 'gotcha' | 'architecture';
|
|
|
|
interface Memory {
|
|
id: number;
|
|
category: MemoryCategory;
|
|
title: string;
|
|
content: string;
|
|
context: string | null;
|
|
project: string | null;
|
|
session_id: string | null;
|
|
task_id: string | null;
|
|
access_count: number;
|
|
created_at: string;
|
|
}
|
|
|
|
interface MemoryAddArgs {
|
|
category: MemoryCategory;
|
|
title: string;
|
|
content: string;
|
|
context?: string;
|
|
project?: string;
|
|
session_id?: string;
|
|
task_id?: string;
|
|
}
|
|
|
|
interface MemorySearchArgs {
|
|
query: string;
|
|
project?: string;
|
|
category?: MemoryCategory;
|
|
limit?: number;
|
|
}
|
|
|
|
interface MemoryListArgs {
|
|
project?: string;
|
|
category?: MemoryCategory;
|
|
limit?: number;
|
|
}
|
|
|
|
/**
|
|
* Add a new memory/learning (enhanced with session_id and task_id)
|
|
* CF-306: Validates session_id exists before inserting to prevent foreign key violations
|
|
*/
|
|
export async function memoryAdd(args: MemoryAddArgs): Promise<string> {
|
|
const { category, title, content, context, project, session_id, task_id } = args;
|
|
|
|
// CF-306: Validate session_id exists if provided
|
|
let validSessionId = session_id || null;
|
|
if (session_id) {
|
|
const sessionExists = await queryOne<{ exists: boolean }>(
|
|
`SELECT EXISTS(SELECT 1 FROM sessions WHERE id = $1) as exists`,
|
|
[session_id]
|
|
);
|
|
if (!sessionExists?.exists) {
|
|
console.warn(`[CF-306] Session ${session_id} not found in database - using NULL instead`);
|
|
validSessionId = null;
|
|
}
|
|
}
|
|
|
|
// CF-1314: Hash content for dedup before embedding API call
|
|
const embedText = `${title}. ${content}`;
|
|
const contentHash = generateContentHash(embedText);
|
|
|
|
// Scope dedup to project if provided, otherwise global
|
|
const existing = project
|
|
? await queryOne<{ id: number }>(
|
|
'SELECT id FROM memories WHERE content_hash = $1 AND project = $2 LIMIT 1',
|
|
[contentHash, project]
|
|
)
|
|
: await queryOne<{ id: number }>(
|
|
'SELECT id FROM memories WHERE content_hash = $1 AND project IS NULL LIMIT 1',
|
|
[contentHash]
|
|
);
|
|
if (existing) {
|
|
return `Memory already exists (id: ${existing.id}): [${category}] ${title}`;
|
|
}
|
|
|
|
// Generate embedding for semantic search
|
|
const embedding = await getEmbedding(embedText);
|
|
const embeddingValue = embedding ? formatEmbedding(embedding) : null;
|
|
|
|
await execute(
|
|
`INSERT INTO memories (category, title, content, context, project, session_id, task_id, embedding, content_hash)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
|
[category, title, content, context || null, project || null, validSessionId, task_id || null, embeddingValue, contentHash]
|
|
);
|
|
|
|
return `Stored memory: [${category}] ${title}`;
|
|
}
|
|
|
|
/**
|
|
* Search memories semantically
|
|
*/
|
|
export async function memorySearch(args: MemorySearchArgs): Promise<string> {
|
|
const { query: searchQuery, project, category, limit = 5 } = args;
|
|
|
|
// Generate embedding for search
|
|
const embedding = await getEmbedding(searchQuery);
|
|
|
|
if (!embedding) {
|
|
return 'Error: Could not generate embedding for search';
|
|
}
|
|
|
|
const embeddingStr = formatEmbedding(embedding);
|
|
|
|
let whereClause = 'WHERE embedding IS NOT NULL';
|
|
const params: unknown[] = [embeddingStr, limit];
|
|
let paramIndex = 3;
|
|
|
|
if (project) {
|
|
whereClause += ` AND (project = $${paramIndex++} OR project IS NULL)`;
|
|
params.splice(params.length - 1, 0, project);
|
|
}
|
|
if (category) {
|
|
whereClause += ` AND category = $${paramIndex++}`;
|
|
params.splice(params.length - 1, 0, category);
|
|
}
|
|
|
|
const memories = await query<Memory & { similarity: number }>(
|
|
`SELECT id, category, title, content, context, project, access_count,
|
|
to_char(created_at, 'YYYY-MM-DD') as created_at,
|
|
1 - (embedding <=> $1) as similarity
|
|
FROM memories
|
|
${whereClause}
|
|
ORDER BY embedding <=> $1
|
|
LIMIT $2`,
|
|
params
|
|
);
|
|
|
|
if (memories.length === 0) {
|
|
return 'No relevant memories found';
|
|
}
|
|
|
|
// Update access_count for returned memories
|
|
const ids = memories.map(m => m.id);
|
|
await execute(
|
|
`UPDATE memories SET access_count = access_count + 1, last_accessed_at = NOW() WHERE id = ANY($1)`,
|
|
[ids]
|
|
);
|
|
|
|
const lines = ['Relevant memories:\n'];
|
|
for (const m of memories) {
|
|
const sim = Math.round(m.similarity * 100);
|
|
const proj = m.project ? ` [${m.project}]` : '';
|
|
lines.push(`**[${m.category}]${proj}** ${m.title} (${sim}% match)`);
|
|
lines.push(` ${m.content}`);
|
|
if (m.context) {
|
|
lines.push(` _Context: ${m.context}_`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* List memories (non-semantic)
|
|
*/
|
|
export async function memoryList(args: MemoryListArgs): Promise<string> {
|
|
const { project, category, limit = 20 } = args;
|
|
|
|
let whereClause = 'WHERE 1=1';
|
|
const params: unknown[] = [];
|
|
let paramIndex = 1;
|
|
|
|
if (project) {
|
|
whereClause += ` AND (project = $${paramIndex++} OR project IS NULL)`;
|
|
params.push(project);
|
|
}
|
|
if (category) {
|
|
whereClause += ` AND category = $${paramIndex++}`;
|
|
params.push(category);
|
|
}
|
|
|
|
params.push(limit);
|
|
|
|
const memories = await query<Memory>(
|
|
`SELECT id, category, title, content, context, project, access_count,
|
|
to_char(created_at, 'YYYY-MM-DD') as created_at
|
|
FROM memories
|
|
${whereClause}
|
|
ORDER BY created_at DESC
|
|
LIMIT $${paramIndex}`,
|
|
params
|
|
);
|
|
|
|
if (memories.length === 0) {
|
|
return `No memories found${project ? ` for project ${project}` : ''}`;
|
|
}
|
|
|
|
const lines = [`Memories${project ? ` (${project})` : ''}:\n`];
|
|
for (const m of memories) {
|
|
const proj = m.project ? `[${m.project}] ` : '';
|
|
const accessed = m.access_count > 0 ? ` (accessed ${m.access_count}x)` : '';
|
|
lines.push(`• [${m.category}] ${proj}${m.title}${accessed}`);
|
|
lines.push(` ${m.content.slice(0, 100)}${m.content.length > 100 ? '...' : ''}`);
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Delete a memory by ID
|
|
*/
|
|
export async function memoryDelete(id: number): Promise<string> {
|
|
const result = await execute('DELETE FROM memories WHERE id = $1', [id]);
|
|
|
|
if (result === 0) {
|
|
return `Memory not found: ${id}`;
|
|
}
|
|
|
|
return `Deleted memory: ${id}`;
|
|
}
|
|
|
|
/**
|
|
* Get memories relevant to current context (for session start)
|
|
*/
|
|
export async function memoryContext(project: string | null, taskDescription?: string): Promise<string> {
|
|
const lines: string[] = [];
|
|
|
|
// Get project-specific memories
|
|
if (project) {
|
|
const projectMemories = await query<Memory>(
|
|
`SELECT category, title, content FROM memories
|
|
WHERE project = $1
|
|
ORDER BY access_count DESC, created_at DESC
|
|
LIMIT 5`,
|
|
[project]
|
|
);
|
|
|
|
if (projectMemories.length > 0) {
|
|
lines.push(`**${project} Memories:**`);
|
|
for (const m of projectMemories) {
|
|
lines.push(`• [${m.category}] ${m.title}: ${m.content}`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
}
|
|
|
|
// If task description provided, do semantic search
|
|
if (taskDescription) {
|
|
const embedding = await getEmbedding(taskDescription);
|
|
if (embedding) {
|
|
const relevant = await query<Memory>(
|
|
`SELECT category, title, content, project
|
|
FROM memories
|
|
WHERE embedding IS NOT NULL
|
|
ORDER BY embedding <=> $1
|
|
LIMIT 3`,
|
|
[formatEmbedding(embedding)]
|
|
);
|
|
|
|
if (relevant.length > 0) {
|
|
lines.push('**Relevant memories for this task:**');
|
|
for (const m of relevant) {
|
|
const proj = m.project ? `[${m.project}] ` : '';
|
|
lines.push(`• ${proj}${m.title}: ${m.content}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get recent gotchas (always useful)
|
|
const gotchas = await query<Memory>(
|
|
`SELECT title, content FROM memories
|
|
WHERE category = 'gotcha'
|
|
ORDER BY created_at DESC
|
|
LIMIT 3`,
|
|
[]
|
|
);
|
|
|
|
if (gotchas.length > 0) {
|
|
lines.push('\n**Recent gotchas:**');
|
|
for (const g of gotchas) {
|
|
lines.push(`⚠️ ${g.title}: ${g.content}`);
|
|
}
|
|
}
|
|
|
|
return lines.length > 0 ? lines.join('\n') : 'No memories to surface';
|
|
}
|