Add project_archives table and MCP tools (CF-264)

- Created migration 009: project_archives table with semantic search
- Implemented archives.ts: archiveAdd, archiveSearch, archiveList, archiveGet
- Registered archive tools in index.ts and tools/index.ts
- Archive types: session, research, audit, investigation, completed, migration
- Uses project_key (TEXT) FK to projects table
- Tested: archive_add and archive_list working correctly

Replaces filesystem archives with database-backed storage.
Eliminates context pollution from Glob/Grep operations.

Task: CF-264
Session: session_20260119111342_66de546b

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-01-19 11:38:48 +02:00
parent a868dd40ec
commit 1231835e02
4 changed files with 391 additions and 0 deletions

264
src/tools/archives.ts Normal file
View File

@@ -0,0 +1,264 @@
// Project archives operations for database-backed archival
import { query, queryOne, execute } from '../db.js';
import { getEmbedding, formatEmbedding } from '../embeddings.js';
type ArchiveType = 'session' | 'research' | 'audit' | 'investigation' | 'completed' | 'migration';
interface Archive {
id: number;
project_key: string;
archive_type: ArchiveType;
title: string;
content: string;
original_path: string | null;
file_size: number | null;
archived_at: string;
archived_by_session: string | null;
metadata: Record<string, unknown>;
created_at: string;
updated_at: string;
}
interface ArchiveAddArgs {
project: string;
archive_type: ArchiveType;
title: string;
content: string;
original_path?: string;
file_size?: number;
archived_by_session?: string;
metadata?: Record<string, unknown>;
}
interface ArchiveSearchArgs {
query: string;
project?: string;
archive_type?: ArchiveType;
limit?: number;
}
interface ArchiveListArgs {
project?: string;
archive_type?: ArchiveType;
since?: string;
limit?: number;
}
interface ArchiveGetArgs {
id: number;
}
/**
* Verify project exists
*/
async function verifyProject(projectKey: string): Promise<boolean> {
const result = await queryOne<{ key: string }>(
'SELECT key FROM projects WHERE key = $1',
[projectKey]
);
return !!result;
}
/**
* Add a new archive entry
*/
export async function archiveAdd(args: ArchiveAddArgs): Promise<string> {
const { project, archive_type, title, content, original_path, file_size, archived_by_session, metadata } = args;
// Verify project exists
const exists = await verifyProject(project);
if (!exists) {
return `Error: Project not found: ${project}`;
}
// Generate embedding for semantic search
const embedText = `${title}. ${content.substring(0, 1000)}`; // Limit content length for embedding
const embedding = await getEmbedding(embedText);
const embeddingValue = embedding ? formatEmbedding(embedding) : null;
if (embeddingValue) {
await execute(
`INSERT INTO project_archives
(project_key, archive_type, title, content, original_path, file_size, archived_by_session, metadata, embedding)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
project,
archive_type,
title,
content,
original_path || null,
file_size || null,
archived_by_session || null,
JSON.stringify(metadata || {}),
embeddingValue
]
);
} else {
await execute(
`INSERT INTO project_archives
(project_key, archive_type, title, content, original_path, file_size, archived_by_session, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
[
project,
archive_type,
title,
content,
original_path || null,
file_size || null,
archived_by_session || null,
JSON.stringify(metadata || {})
]
);
}
const sizeStr = file_size ? ` (${Math.round(file_size / 1024)}KB)` : '';
return `Archived: [${archive_type}] ${title}${sizeStr}`;
}
/**
* Search archives semantically
*/
export async function archiveSearch(args: ArchiveSearchArgs): Promise<string> {
const { query: searchQuery, project, archive_type, 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_key = $${paramIndex++}`;
params.splice(params.length - 1, 0, project);
}
if (archive_type) {
whereClause += ` AND archive_type = $${paramIndex++}`;
params.splice(params.length - 1, 0, archive_type);
}
const archives = await query<Archive & { similarity: number }>(
`SELECT id, archive_type, title, original_path, file_size,
to_char(archived_at, 'YYYY-MM-DD') as archived_at,
1 - (embedding <=> $1) as similarity
FROM project_archives
${whereClause}
ORDER BY embedding <=> $1
LIMIT $2`,
params
);
if (archives.length === 0) {
return 'No relevant archives found';
}
const lines = ['Relevant archives:\n'];
for (const a of archives) {
const sim = Math.round(a.similarity * 100);
const sizeStr = a.file_size ? ` (${Math.round(a.file_size / 1024)}KB)` : '';
lines.push(`**[${a.archive_type}]** ${a.title} (${sim}% match)`);
lines.push(` Archived: ${a.archived_at}${sizeStr}`);
if (a.original_path) {
lines.push(` Path: ${a.original_path}`);
}
lines.push('');
}
return lines.join('\n');
}
/**
* List archives (non-semantic)
*/
export async function archiveList(args: ArchiveListArgs): Promise<string> {
const { project, archive_type, since, limit = 20 } = args;
let whereClause = 'WHERE 1=1';
const params: unknown[] = [];
let paramIndex = 1;
if (project) {
whereClause += ` AND project_key = $${paramIndex++}`;
params.push(project);
}
if (archive_type) {
whereClause += ` AND archive_type = $${paramIndex++}`;
params.push(archive_type);
}
if (since) {
whereClause += ` AND archived_at >= $${paramIndex++}`;
params.push(since);
}
params.push(limit);
const archives = await query<Archive>(
`SELECT id, archive_type, title, original_path, file_size,
to_char(archived_at, 'YYYY-MM-DD') as archived_at
FROM project_archives
${whereClause}
ORDER BY archived_at DESC
LIMIT $${paramIndex}`,
params
);
if (archives.length === 0) {
return `No archives found${project ? ` for project ${project}` : ''}`;
}
const lines = [`Archives${project ? ` (${project})` : ''}:\n`];
for (const a of archives) {
const sizeStr = a.file_size ? ` (${Math.round(a.file_size / 1024)}KB)` : '';
lines.push(`• [${a.archive_type}] ${a.title} - ${a.archived_at}${sizeStr}`);
if (a.original_path) {
lines.push(` ${a.original_path}`);
}
}
return lines.join('\n');
}
/**
* Get specific archive by ID
*/
export async function archiveGet(args: ArchiveGetArgs): Promise<string> {
const archive = await queryOne<Archive>(
`SELECT id, project_key, archive_type, title, content, original_path, file_size,
to_char(archived_at, 'YYYY-MM-DD') as archived_at,
archived_by_session, metadata
FROM project_archives
WHERE id = $1`,
[args.id]
);
if (!archive) {
return `Archive not found: ${args.id}`;
}
const sizeStr = archive.file_size ? ` (${Math.round(archive.file_size / 1024)}KB)` : '';
const lines = [
`# Archive #${archive.id}\n`,
`**Type:** ${archive.archive_type}`,
`**Title:** ${archive.title}`,
`**Archived:** ${archive.archived_at}${sizeStr}`,
];
if (archive.original_path) {
lines.push(`**Original Path:** ${archive.original_path}`);
}
if (archive.archived_by_session) {
lines.push(`**Session:** ${archive.archived_by_session}`);
}
lines.push('\n---\n');
lines.push(archive.content);
return lines.join('\n');
}