Files
session-mcp/src/tools/session-docs.ts
Christian Gick 4f8996cd82 feat(CF-1315): Hybrid search with tsvector + RRF
Add PostgreSQL full-text search alongside pgvector for exact matches
on Jira keys, error messages, file paths. Merge results with
Reciprocal Rank Fusion. Default mode: hybrid, with graceful
degradation to keyword-only when embeddings unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 08:46:39 +02:00

698 lines
20 KiB
TypeScript

// Session documentation operations - notes, plans, and project documentation
// Replaces file-based CLAUDE.md and plan files with database storage
import { query, queryOne, execute } from '../db.js';
import { getEmbedding, formatEmbedding, generateContentHash, rrfMerge } from '../embeddings.js';
import { getSessionId } from './session-id.js';
// ============================================================================
// SESSION NOTES
// ============================================================================
interface SessionNoteAddArgs {
session_id: string;
note_type: 'accomplishment' | 'decision' | 'gotcha' | 'next_steps' | 'context';
content: string;
}
interface SessionNotesListArgs {
session_id: string;
note_type?: 'accomplishment' | 'decision' | 'gotcha' | 'next_steps' | 'context';
}
interface SessionNote {
id: number;
session_id: string;
note_type: string;
content: string;
created_at: string;
}
/**
* Add a note to current session
* Auto-generates embedding for semantic search
*/
export async function sessionNoteAdd(args: SessionNoteAddArgs): Promise<string> {
const { session_id: providedSessionId, note_type, content } = args;
const session_id = providedSessionId || getSessionId();
// CF-1314: Hash content for dedup before embedding API call
const contentHash = generateContentHash(content);
const existing = await queryOne<{ id: number }>(
'SELECT id FROM session_notes WHERE content_hash = $1 AND session_id = $2 LIMIT 1',
[contentHash, session_id]
);
if (existing) {
return `Note already exists (id: ${existing.id}) in session ${session_id}`;
}
// Generate embedding for semantic search
const embedding = await getEmbedding(content);
const embeddingFormatted = embedding ? formatEmbedding(embedding) : null;
await execute(
`INSERT INTO session_notes (session_id, note_type, content, embedding, content_hash)
VALUES ($1, $2, $3, $4, $5)`,
[session_id, note_type, content, embeddingFormatted, contentHash]
);
return `Note added to session ${session_id} (type: ${note_type})`;
}
/**
* List all notes for a session, optionally filtered by type
*/
export async function sessionNotesList(args: SessionNotesListArgs): Promise<SessionNote[]> {
const { session_id, note_type } = args;
let sql = `
SELECT id, session_id, note_type, content, created_at
FROM session_notes
WHERE session_id = $1
`;
const params: unknown[] = [session_id];
if (note_type) {
sql += ` AND note_type = $2`;
params.push(note_type);
}
sql += ` ORDER BY created_at ASC`;
const notes = await query<SessionNote>(sql, params);
return notes;
}
// ============================================================================
// SESSION PLANS
// ============================================================================
interface SessionPlanSaveArgs {
session_id: string;
plan_content: string;
plan_file_name?: string;
status?: 'draft' | 'approved' | 'executed' | 'abandoned';
}
interface SessionPlanUpdateStatusArgs {
plan_id: number;
status: 'draft' | 'approved' | 'executed' | 'abandoned';
}
interface SessionPlanListArgs {
session_id: string;
status?: 'draft' | 'approved' | 'executed' | 'abandoned';
}
interface SessionPlan {
id: number;
session_id: string;
plan_file_name: string | null;
plan_content: string;
status: string;
created_at: string;
approved_at: string | null;
completed_at: string | null;
}
/**
* Save a plan to database
* Extracts embedding from plan content for semantic search
*/
export async function sessionPlanSave(args: SessionPlanSaveArgs): Promise<string> {
const { session_id, plan_content, plan_file_name, status = 'draft' } = args;
// CF-1314: Hash content for dedup before embedding API call
const contentHash = generateContentHash(plan_content);
const existing = await queryOne<{ id: number }>(
'SELECT id FROM session_plans WHERE content_hash = $1 AND session_id = $2 LIMIT 1',
[contentHash, session_id]
);
if (existing) {
return `Plan already exists (id: ${existing.id}) in session ${session_id}`;
}
// Generate embedding for semantic search
const embedding = await getEmbedding(plan_content);
const embeddingFormatted = embedding ? formatEmbedding(embedding) : null;
const result = await queryOne<{ id: number }>(
`INSERT INTO session_plans (session_id, plan_file_name, plan_content, status, embedding, content_hash)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id`,
[session_id, plan_file_name || null, plan_content, status, embeddingFormatted, contentHash]
);
const planId = result?.id || 0;
return `Plan saved to session ${session_id} (plan_id: ${planId}, status: ${status})`;
}
/**
* Update plan status (draft → approved → executed)
*/
export async function sessionPlanUpdateStatus(args: SessionPlanUpdateStatusArgs): Promise<string> {
const { plan_id, status } = args;
// Set timestamps based on status
let sql = 'UPDATE session_plans SET status = $1';
const params: unknown[] = [status, plan_id];
if (status === 'approved') {
sql += ', approved_at = NOW()';
} else if (status === 'executed' || status === 'abandoned') {
sql += ', completed_at = NOW()';
}
sql += ' WHERE id = $2';
await execute(sql, params);
return `Plan ${plan_id} status updated to: ${status}`;
}
/**
* List plans for a session
*/
export async function sessionPlanList(args: SessionPlanListArgs): Promise<SessionPlan[]> {
const { session_id, status } = args;
let sql = `
SELECT id, session_id, plan_file_name, plan_content, status,
created_at, approved_at, completed_at
FROM session_plans
WHERE session_id = $1
`;
const params: unknown[] = [session_id];
if (status) {
sql += ` AND status = $2`;
params.push(status);
}
sql += ` ORDER BY created_at DESC`;
const plans = await query<SessionPlan>(sql, params);
return plans;
}
// ============================================================================
// PROJECT DOCUMENTATION
// ============================================================================
interface ProjectDocUpsertArgs {
project: string;
doc_type: 'overview' | 'architecture' | 'guidelines' | 'history';
title: string;
content: string;
session_id?: string;
}
interface ProjectDocGetArgs {
project: string;
doc_type: 'overview' | 'architecture' | 'guidelines' | 'history';
}
interface ProjectDocListArgs {
project: string;
}
interface ProjectDoc {
id: number;
project: string;
doc_type: string;
title: string;
content: string;
last_updated_session: string | null;
created_at: string;
updated_at: string;
}
/**
* Create or update project documentation
* Links to current session
*/
export async function projectDocUpsert(args: ProjectDocUpsertArgs): Promise<string> {
const { project, doc_type, title, content, session_id } = args;
await execute(
`INSERT INTO project_documentation (project, doc_type, title, content, last_updated_session, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (project, doc_type)
DO UPDATE SET
title = EXCLUDED.title,
content = EXCLUDED.content,
last_updated_session = EXCLUDED.last_updated_session,
updated_at = NOW()`,
[project, doc_type, title, content, session_id || null]
);
return `Project documentation updated: ${project} (${doc_type})`;
}
/**
* Get specific project documentation by type
*/
export async function projectDocGet(args: ProjectDocGetArgs): Promise<ProjectDoc | null> {
const { project, doc_type } = args;
const doc = await queryOne<ProjectDoc>(
`SELECT id, project, doc_type, title, content, last_updated_session, created_at, updated_at
FROM project_documentation
WHERE project = $1 AND doc_type = $2`,
[project, doc_type]
);
return doc;
}
/**
* List all documentation for a project
*/
export async function projectDocList(args: ProjectDocListArgs): Promise<ProjectDoc[]> {
const { project } = args;
const docs = await query<ProjectDoc>(
`SELECT id, project, doc_type, title, content, last_updated_session, created_at, updated_at
FROM project_documentation
WHERE project = $1
ORDER BY doc_type`,
[project]
);
return docs;
}
// ============================================================================
// SESSION DOCUMENTATION GENERATION
// ============================================================================
interface SessionDocumentationGenerateArgs {
session_id: string;
}
/**
* Auto-generate full markdown documentation for a session
* Aggregates: tasks, commits, notes, decisions, plans
* Stores in sessions.documentation field
*/
export async function sessionDocumentationGenerate(args: SessionDocumentationGenerateArgs): Promise<string> {
const { session_id } = args;
// Fetch session details
const session = await queryOne<{
id: string;
project: string;
session_number: number;
started_at: string;
ended_at: string;
duration_minutes: number;
summary: string;
git_branch: string;
}>(
`SELECT id, project, session_number, started_at, ended_at, duration_minutes, summary, git_branch
FROM sessions
WHERE id = $1`,
[session_id]
);
if (!session) {
throw new Error(`Session not found: ${session_id}`);
}
// Fetch tasks worked on
const tasks = await query<{ task_id: string; title: string; status: string }>(
`SELECT DISTINCT t.id as task_id, t.title, t.status
FROM task_activity ta
JOIN tasks t ON ta.task_id = t.id
WHERE ta.session_id = $1
ORDER BY t.id`,
[session_id]
);
// Fetch commits made
const commits = await query<{ commit_sha: string; commit_message: string }>(
`SELECT commit_sha, commit_message
FROM session_commits
WHERE session_id = $1
ORDER BY committed_at`,
[session_id]
);
// Fetch notes
const notes = await query<{ note_type: string; content: string }>(
`SELECT note_type, content
FROM session_notes
WHERE session_id = $1
ORDER BY created_at`,
[session_id]
);
// Fetch plans
const plans = await query<{ plan_content: string; status: string }>(
`SELECT plan_content, status
FROM session_plans
WHERE session_id = $1
ORDER BY created_at DESC
LIMIT 1`,
[session_id]
);
// Build markdown documentation
let doc = `# Session ${session.session_number} - ${session.project}\n\n`;
doc += `**Date:** ${new Date(session.started_at).toLocaleDateString()}\n`;
doc += `**Duration:** ${session.duration_minutes || 0} minutes\n`;
doc += `**Status:** ${session.summary ? '✅ Complete' : '⚠️ In Progress'}\n`;
if (session.git_branch) {
doc += `**Branch:** ${session.git_branch}\n`;
}
doc += `\n---\n\n`;
// Summary
if (session.summary) {
doc += `## Summary\n\n${session.summary}\n\n`;
}
// Tasks
if (tasks.length > 0) {
doc += `## Tasks Completed\n\n`;
tasks.forEach(task => {
doc += `- **${task.task_id}**: ${task.title} (${task.status})\n`;
});
doc += `\n`;
}
// Commits
if (commits.length > 0) {
doc += `## Commits\n\n`;
commits.forEach(commit => {
const shortSha = commit.commit_sha.substring(0, 7);
doc += `- \`${shortSha}\` - ${commit.commit_message || 'No message'}\n`;
});
doc += `\n`;
}
// Notes by type
const notesByType: Record<string, string[]> = {};
notes.forEach(note => {
if (!notesByType[note.note_type]) {
notesByType[note.note_type] = [];
}
notesByType[note.note_type].push(note.content);
});
if (notesByType.accomplishment && notesByType.accomplishment.length > 0) {
doc += `## Accomplishments\n\n`;
notesByType.accomplishment.forEach(content => {
doc += `${content}\n\n`;
});
}
if (notesByType.decision && notesByType.decision.length > 0) {
doc += `## Key Decisions\n\n`;
notesByType.decision.forEach(content => {
doc += `${content}\n\n`;
});
}
if (notesByType.gotcha && notesByType.gotcha.length > 0) {
doc += `## Gotchas & Learnings\n\n`;
notesByType.gotcha.forEach(content => {
doc += `${content}\n\n`;
});
}
if (notesByType.next_steps && notesByType.next_steps.length > 0) {
doc += `## Next Steps\n\n`;
notesByType.next_steps.forEach(content => {
doc += `${content}\n\n`;
});
}
// Plan (if exists)
if (plans.length > 0 && plans[0].status === 'executed') {
doc += `## Plan\n\n${plans[0].plan_content}\n\n`;
}
// Store documentation in sessions table
await execute(
`UPDATE sessions SET documentation = $1 WHERE id = $2`,
[doc, session_id]
);
return `Documentation generated for session ${session_id} (${doc.length} characters)`;
}
// ============================================================================
// SEMANTIC SEARCH & ANALYTICS
// ============================================================================
type SearchMode = 'hybrid' | 'vector' | 'keyword';
interface SessionSemanticSearchArgs {
query: string;
project?: string;
limit?: number;
search_mode?: SearchMode;
}
interface SessionSearchResult {
session_id: string;
session_number: number;
project: string;
summary: string | null;
started_at: string;
similarity: number;
}
/**
* Semantic search across all session documentation with hybrid/vector/keyword modes (CF-1315)
*/
export async function sessionSemanticSearch(args: SessionSemanticSearchArgs): Promise<SessionSearchResult[]> {
const { query: searchQuery, project, limit = 10, search_mode = 'hybrid' } = args;
// Build shared filter clause
const buildFilter = (startIdx: number) => {
let where = '';
const params: unknown[] = [];
let idx = startIdx;
if (project) {
where += ` AND s.project = $${idx++}`;
params.push(project);
}
return { where, params, nextIdx: idx };
};
// Vector search
let vectorIds: string[] = [];
let vectorRows: Map<string, SessionSearchResult> = new Map();
let embeddingFailed = false;
if (search_mode !== 'keyword') {
const queryEmbedding = await getEmbedding(searchQuery);
if (queryEmbedding) {
const embeddingFormatted = formatEmbedding(queryEmbedding);
const filter = buildFilter(3);
const params: unknown[] = [embeddingFormatted, limit, ...filter.params];
const rows = await query<SessionSearchResult>(
`SELECT s.id as session_id, s.session_number, s.project, s.summary, s.started_at,
1 - (s.embedding <=> $1) as similarity
FROM sessions s
WHERE s.embedding IS NOT NULL AND s.status = 'completed'${filter.where}
ORDER BY s.embedding <=> $1
LIMIT $2`,
params
);
vectorIds = rows.map(r => r.session_id);
for (const r of rows) vectorRows.set(r.session_id, r);
} else {
embeddingFailed = true;
if (search_mode === 'vector') {
return [];
}
}
}
// Keyword search
let keywordIds: string[] = [];
let keywordRows: Map<string, SessionSearchResult> = new Map();
if (search_mode !== 'vector') {
const filter = buildFilter(3);
const params: unknown[] = [searchQuery, limit, ...filter.params];
const rows = await query<SessionSearchResult & { rank: number }>(
`SELECT s.id as session_id, s.session_number, s.project, s.summary, s.started_at,
ts_rank(s.search_vector, plainto_tsquery('english', $1)) as similarity
FROM sessions s
WHERE s.search_vector @@ plainto_tsquery('english', $1)
AND s.status = 'completed'${filter.where}
ORDER BY similarity DESC
LIMIT $2`,
params
);
keywordIds = rows.map(r => r.session_id);
for (const r of rows) keywordRows.set(r.session_id, r);
}
// Merge results
let finalIds: string[];
if (search_mode === 'hybrid' && vectorIds.length > 0 && keywordIds.length > 0) {
const merged = rrfMerge(vectorIds, keywordIds);
finalIds = merged.slice(0, limit).map(m => m.id as string);
} else if (vectorIds.length > 0) {
finalIds = vectorIds;
} else if (keywordIds.length > 0) {
finalIds = keywordIds;
} else {
return [];
}
// Build final results preserving original similarity scores
const results: SessionSearchResult[] = [];
for (const id of finalIds) {
const r = vectorRows.get(id) || keywordRows.get(id);
if (r) results.push(r);
}
return results;
}
interface SessionAnalyticsArgs {
project?: string;
time_period?: 'week' | 'month' | 'quarter';
}
interface SessionAnalytics {
total_sessions: number;
avg_duration_minutes: number;
avg_tasks_per_session: number;
avg_commits_per_session: number;
avg_notes_per_session: number;
total_tokens_used: number;
}
/**
* Get productivity analytics for sessions
*/
export async function sessionProductivityAnalytics(args: SessionAnalyticsArgs): Promise<SessionAnalytics> {
const { project, time_period = 'month' } = args;
// Map time period to interval
const intervalMap = {
week: '7 days',
month: '30 days',
quarter: '90 days',
};
const interval = intervalMap[time_period];
const sql = `
WITH session_stats AS (
SELECT
s.id,
s.duration_minutes,
s.token_count,
COUNT(DISTINCT ta.task_id) as tasks_touched,
COUNT(DISTINCT sc.commit_sha) as commits_made,
COUNT(sn.id) as notes_created
FROM sessions s
LEFT JOIN task_activity ta ON s.id = ta.session_id
LEFT JOIN session_commits sc ON s.id = sc.session_id
LEFT JOIN session_notes sn ON s.id = sn.session_id
WHERE s.status = 'completed'
AND s.started_at >= NOW() - INTERVAL '${interval}'
${project ? 'AND s.project = $1' : ''}
GROUP BY s.id, s.duration_minutes, s.token_count
)
SELECT
COUNT(*)::int as total_sessions,
COALESCE(AVG(duration_minutes), 0)::int as avg_duration_minutes,
COALESCE(AVG(tasks_touched), 0)::numeric(10,1) as avg_tasks_per_session,
COALESCE(AVG(commits_made), 0)::numeric(10,1) as avg_commits_per_session,
COALESCE(AVG(notes_created), 0)::numeric(10,1) as avg_notes_per_session,
COALESCE(SUM(token_count), 0)::bigint as total_tokens_used
FROM session_stats;
`;
const result = await queryOne<SessionAnalytics>(sql, project ? [project] : []);
return result || {
total_sessions: 0,
avg_duration_minutes: 0,
avg_tasks_per_session: 0,
avg_commits_per_session: 0,
avg_notes_per_session: 0,
total_tokens_used: 0,
};
}
interface SessionPatternArgs {
project?: string;
pattern_type?: 'tool_usage' | 'task_types' | 'error_frequency';
}
interface Pattern {
pattern: string;
frequency: number;
avg_session_duration: number;
sessions_count: number;
}
/**
* Detect patterns across sessions (tool usage, task types, etc.)
*/
export async function sessionPatternDetection(args: SessionPatternArgs): Promise<Pattern[]> {
const { project, pattern_type = 'tool_usage' } = args;
if (pattern_type === 'tool_usage') {
// Analyze tool usage patterns
const sql = `
SELECT
unnest(s.tools_used) as pattern,
COUNT(*) as frequency,
AVG(s.duration_minutes)::int as avg_session_duration,
COUNT(DISTINCT s.id) as sessions_count
FROM sessions s
WHERE s.status = 'completed'
AND s.tools_used IS NOT NULL
${project ? 'AND s.project = $1' : ''}
GROUP BY pattern
HAVING COUNT(*) > 3
ORDER BY frequency DESC
LIMIT 20;
`;
const results = await query<Pattern>(sql, project ? [project] : []);
return results;
} else if (pattern_type === 'task_types') {
// Analyze task type patterns
const sql = `
SELECT
t.type as pattern,
COUNT(*) as frequency,
AVG(s.duration_minutes)::int as avg_session_duration,
COUNT(DISTINCT s.id) as sessions_count
FROM sessions s
JOIN task_activity ta ON s.id = ta.session_id
JOIN tasks t ON ta.task_id = t.id
WHERE s.status = 'completed'
${project ? 'AND s.project = $1' : ''}
GROUP BY t.type
HAVING COUNT(*) > 3
ORDER BY frequency DESC;
`;
const results = await query<Pattern>(sql, project ? [project] : []);
return results;
}
return [];
}