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>
246 lines
6.3 KiB
TypeScript
246 lines
6.3 KiB
TypeScript
// 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;
|
||
}
|