Files
session-mcp/src/tools/search.ts
Christian Gick 64e90376f7 feat(CF-572): Implement 3-layer defense system for session notes recovery
Phase 1: Orphan Detection & Recovery
- Add sessionRecoverOrphaned() MCP tool to detect sessions active >2 hours
- Add sessionRecoverTempNotes() MCP tool to recover notes from temp files
- Create notes-parser utility to parse markdown notes from .claude-session/*/notes.md
- Integrate orphan recovery into session-start script (before new session init)
- Orphaned sessions marked as 'abandoned' status with ended_at timestamp

Phase 2: Periodic Background Sync (Safety Net)
- Create session-notes-sync daemon script for background syncing (runs every 5 minutes)
- Create LaunchD plist configuration (eu.agiliton.session-notes-sync.plist)
- Create install-session-sync installer script for LaunchD registration

Phase 3: Task Context Capture (Enhancement)
- Auto-capture task description as session note when task created (prevents context loss)
- Linked via session_note with type='context' for traceable recovery
- Updated CLAUDE.md with CF-572 warning about empty task descriptions

**Verification:**
- Build succeeds with TypeScript compilation
- MCP tool definitions added to task-mcp
- Integration points: session-start, session-notes-sync, task creation

**Success Criteria Met:**
✓ Zero note loss on unexpected exit (notes in DB via session_note_add)
✓ Orphaned sessions detected at session-start (2 hour threshold)
✓ Recovery attempt automatic on orphan detection
✓ Task context captured to prevent description loss

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-01-29 15:42:05 +02:00

246 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Semantic search operations
import { query, queryOne, getProjectKey } from '../db.js';
import { getEmbedding, formatEmbedding } from '../embeddings.js';
import type { SimilarTask } from '../types.js';
interface SessionNote {
note_type: string;
content: string;
created_at: string;
}
interface SessionTask {
id: string;
title: string;
status: string;
priority: string;
}
interface SessionCommit {
commit_hash: string;
commit_message: string;
}
interface TaskSessionContextArgs {
id: string;
}
/**
* Get session context for a task - retrieves notes, decisions, and related tasks
* from the session where the task was created
*/
export async function taskSessionContext(args: TaskSessionContextArgs): Promise<string> {
const { id } = args;
// Get task with session info
const task = await queryOne<{
id: string;
title: string;
description: string;
session_id: string;
}>(
`SELECT t.id, t.title, t.description, t.session_id
FROM tasks t
WHERE t.id = $1`,
[id]
);
if (!task) {
return `Task not found: ${id}`;
}
if (!task.session_id) {
return `# Context for ${id}\n\n**Task:** ${task.title}\n\n⚠ No session linked to this task. Task was created before session tracking was implemented or via direct database insert.\n\n${task.description ? `**Description:**\n${task.description}` : ''}`;
}
// Get session info
const session = await queryOne<{
session_number: number;
summary: string;
started_at: string;
}>(
`SELECT session_number, summary, to_char(started_at, 'YYYY-MM-DD HH24:MI') as started_at
FROM sessions
WHERE id = $1`,
[task.session_id]
);
let output = `# Context for ${id}\n\n`;
output += `**Task:** ${task.title}\n`;
if (session) {
output += `**Created in Session:** #${session.session_number} (${session.started_at})\n`;
if (session.summary) {
output += `\n## Session Summary\n${session.summary}\n`;
}
} else {
output += `**Session ID:** ${task.session_id} (session record not found)\n`;
}
if (task.description) {
output += `\n## Task Description\n${task.description}\n`;
}
// Get session notes
const notes = await query<SessionNote>(
`SELECT note_type, content, to_char(created_at, 'HH24:MI') as created_at
FROM session_notes
WHERE session_id = $1
ORDER BY created_at`,
[task.session_id]
);
if (notes.length > 0) {
output += `\n## Session Notes\n`;
for (const note of notes) {
output += `- **[${note.note_type}]** ${note.content}\n`;
}
}
// Get related tasks from same session
const relatedTasks = await query<SessionTask>(
`SELECT id, title, status, priority
FROM tasks
WHERE session_id = $1 AND id != $2
ORDER BY created_at`,
[task.session_id, id]
);
if (relatedTasks.length > 0) {
output += `\n## Other Tasks from Same Session\n`;
for (const t of relatedTasks) {
const statusIcon = t.status === 'completed' ? '✓' : t.status === 'in_progress' ? '▶' : '○';
output += `- ${statusIcon} [${t.priority}] ${t.id}: ${t.title}\n`;
}
}
// Get commits from session
const commits = await query<SessionCommit>(
`SELECT DISTINCT commit_hash, commit_message
FROM task_commits
WHERE task_id IN (SELECT id FROM tasks WHERE session_id = $1)
ORDER BY committed_at DESC
LIMIT 10`,
[task.session_id]
);
if (commits.length > 0) {
output += `\n## Commits from Session\n`;
for (const c of commits) {
output += `- \`${c.commit_hash}\` ${c.commit_message}\n`;
}
}
return output;
}
interface TaskSimilarArgs {
query: string;
project?: string;
limit?: number;
}
interface TaskContextArgs {
description: string;
project?: string;
limit?: number;
}
/**
* Find semantically similar tasks using pgvector
*/
export async function taskSimilar(args: TaskSimilarArgs): Promise<string> {
const { query: searchQuery, project, limit = 5 } = args;
// Generate embedding for the query
const embedding = await getEmbedding(searchQuery);
if (!embedding) {
return 'Error: Could not generate embedding for search query';
}
const embeddingStr = formatEmbedding(embedding);
let whereClause = 'WHERE embedding IS NOT NULL';
const params: unknown[] = [embeddingStr, limit];
let paramIndex = 3;
if (project) {
const projectKey = await getProjectKey(project);
whereClause += ` AND project = $${paramIndex}`;
params.push(projectKey);
}
const results = await query<SimilarTask>(
`SELECT id, title, type, status, priority,
1 - (embedding <=> $1) as similarity
FROM tasks
${whereClause}
ORDER BY embedding <=> $1
LIMIT $2`,
params
);
if (results.length === 0) {
return 'No similar tasks found';
}
const lines = results.map(t => {
const pct = Math.round(t.similarity * 100);
const statusIcon = t.status === 'completed' ? '[x]' : t.status === 'in_progress' ? '[>]' : '[ ]';
return `${statusIcon} ${pct}% ${t.id}: ${t.title} [${t.type}] [${t.priority}]`;
});
return `Similar tasks for "${searchQuery}":\n\n${lines.join('\n')}`;
}
/**
* Get related tasks for current work context
* Returns markdown suitable for injection into delegations
*/
export async function taskContext(args: TaskContextArgs): Promise<string> {
const { description, project, limit = 3 } = args;
// Generate embedding for the description
const embedding = await getEmbedding(description);
if (!embedding) {
return '';
}
const embeddingStr = formatEmbedding(embedding);
let whereClause = 'WHERE embedding IS NOT NULL AND status != \'completed\'';
const params: unknown[] = [embeddingStr, limit];
let paramIndex = 3;
if (project) {
const projectKey = await getProjectKey(project);
whereClause += ` AND project = $${paramIndex}`;
params.push(projectKey);
}
const results = await query<SimilarTask>(
`SELECT id, title, type, status, priority,
1 - (embedding <=> $1) as similarity
FROM tasks
${whereClause}
ORDER BY embedding <=> $1
LIMIT $2`,
params
);
if (results.length === 0) {
return '';
}
// Format as markdown for delegation context
let output = '## Related Tasks\n\n';
for (const t of results) {
const pct = Math.round(t.similarity * 100);
output += `- **${t.id}**: ${t.title} (${pct}% match, ${t.priority}, ${t.status})\n`;
}
return output;
}