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>
689 lines
21 KiB
TypeScript
689 lines
21 KiB
TypeScript
// Session management operations for database-driven session tracking
|
|
// Sessions auto-create CF Jira issues and post output on close (CF-762)
|
|
|
|
import { query, queryOne, execute } from '../db.js';
|
|
import { getEmbedding, formatEmbedding, generateContentHash } from '../embeddings.js';
|
|
import { createSessionIssue, addComment, transitionToDone, updateIssueDescription } from '../services/jira.js';
|
|
|
|
interface SessionStartArgs {
|
|
session_id?: string;
|
|
project: string;
|
|
working_directory?: string;
|
|
git_branch?: string;
|
|
initial_prompt?: string;
|
|
jira_issue_key?: string;
|
|
}
|
|
|
|
interface SessionUpdateArgs {
|
|
session_id: string;
|
|
message_count?: number;
|
|
token_count?: number;
|
|
tools_used?: string[];
|
|
}
|
|
|
|
interface SessionEndArgs {
|
|
session_id: string;
|
|
summary: string;
|
|
status?: 'completed' | 'interrupted';
|
|
}
|
|
|
|
interface SessionListArgs {
|
|
project?: string;
|
|
status?: 'active' | 'completed' | 'interrupted';
|
|
since?: string;
|
|
limit?: number;
|
|
}
|
|
|
|
interface SessionSearchArgs {
|
|
query: string;
|
|
project?: string;
|
|
limit?: number;
|
|
}
|
|
|
|
interface Session {
|
|
id: string;
|
|
project: string | null;
|
|
session_number: number | null;
|
|
started_at: string;
|
|
ended_at: string | null;
|
|
duration_minutes: number | null;
|
|
working_directory: string | null;
|
|
git_branch: string | null;
|
|
initial_prompt: string | null;
|
|
summary: string | null;
|
|
message_count: number;
|
|
token_count: number;
|
|
tools_used: string[] | null;
|
|
status: string;
|
|
jira_issue_key: string | null;
|
|
created_at: string;
|
|
}
|
|
|
|
/**
|
|
* Start a new session with metadata tracking.
|
|
* Auto-creates a CF Jira issue for session tracking.
|
|
* Returns session_id, session_number, and Jira issue key.
|
|
*/
|
|
export async function sessionStart(args: SessionStartArgs): Promise<string> {
|
|
const { session_id, project, working_directory, git_branch, initial_prompt, jira_issue_key } = args;
|
|
|
|
// Generate session ID if not provided (fallback, should come from session-memory)
|
|
const id = session_id || `session_${Date.now()}_${Math.random().toString(36).substring(7)}`;
|
|
|
|
await execute(
|
|
`INSERT INTO sessions (id, project, started_at, working_directory, git_branch, initial_prompt, jira_issue_key, status)
|
|
VALUES ($1, $2, NOW(), $3, $4, $5, $6, 'active')`,
|
|
[id, project, working_directory || null, git_branch || null, initial_prompt || null, jira_issue_key || null]
|
|
);
|
|
|
|
// Get the assigned session_number
|
|
const result = await queryOne<{ session_number: number }>(
|
|
'SELECT session_number FROM sessions WHERE id = $1',
|
|
[id]
|
|
);
|
|
|
|
const session_number = result?.session_number || null;
|
|
|
|
// Auto-create CF Jira issue for session tracking (non-blocking)
|
|
let sessionJiraKey: string | null = jira_issue_key || null;
|
|
if (!sessionJiraKey) {
|
|
try {
|
|
const jiraResult = await createSessionIssue({
|
|
sessionNumber: session_number,
|
|
project,
|
|
parentIssueKey: jira_issue_key || undefined,
|
|
branch: git_branch || undefined,
|
|
workingDirectory: working_directory || undefined,
|
|
});
|
|
if (jiraResult) {
|
|
sessionJiraKey = jiraResult.key;
|
|
// Store the auto-created Jira issue key
|
|
await execute(
|
|
`UPDATE sessions SET jira_issue_key = $1 WHERE id = $2`,
|
|
[sessionJiraKey, id]
|
|
);
|
|
}
|
|
} catch (err) {
|
|
console.error('session-mcp: Failed to create session Jira issue:', err);
|
|
}
|
|
}
|
|
|
|
const jiraInfo = sessionJiraKey ? ` [${sessionJiraKey}]` : '';
|
|
return `Session started: ${id} (${project} #${session_number})${jiraInfo}`;
|
|
}
|
|
|
|
/**
|
|
* Update session metadata during execution
|
|
*/
|
|
export async function sessionUpdate(args: SessionUpdateArgs): Promise<string> {
|
|
const { session_id, message_count, token_count, tools_used } = args;
|
|
|
|
const updates: string[] = [];
|
|
const params: unknown[] = [];
|
|
let paramIndex = 1;
|
|
|
|
if (message_count !== undefined) {
|
|
updates.push(`message_count = $${paramIndex++}`);
|
|
params.push(message_count);
|
|
}
|
|
if (token_count !== undefined) {
|
|
updates.push(`token_count = $${paramIndex++}`);
|
|
params.push(token_count);
|
|
}
|
|
if (tools_used !== undefined) {
|
|
updates.push(`tools_used = $${paramIndex++}`);
|
|
params.push(tools_used);
|
|
}
|
|
|
|
if (updates.length === 0) {
|
|
return 'No updates provided';
|
|
}
|
|
|
|
updates.push(`updated_at = NOW()`);
|
|
params.push(session_id);
|
|
|
|
await execute(
|
|
`UPDATE sessions SET ${updates.join(', ')} WHERE id = $${paramIndex}`,
|
|
params
|
|
);
|
|
|
|
return `Session updated: ${session_id}`;
|
|
}
|
|
|
|
/**
|
|
* End session and generate summary with embedding.
|
|
* Posts full session output as Jira comment and transitions session issue to Done.
|
|
*/
|
|
export async function sessionEnd(args: SessionEndArgs): Promise<string> {
|
|
const { session_id, summary, status = 'completed' } = args;
|
|
|
|
// CF-1314: Store content hash alongside embedding
|
|
const contentHash = generateContentHash(summary);
|
|
|
|
// Generate embedding for semantic search
|
|
const embedding = await getEmbedding(summary);
|
|
const embeddingValue = embedding ? formatEmbedding(embedding) : null;
|
|
|
|
await execute(
|
|
`UPDATE sessions
|
|
SET ended_at = NOW(),
|
|
summary = $1,
|
|
embedding = $2,
|
|
status = $3,
|
|
content_hash = $4,
|
|
updated_at = NOW()
|
|
WHERE id = $5`,
|
|
[summary, embeddingValue, status, contentHash, session_id]
|
|
);
|
|
|
|
// Get session details
|
|
const session = await queryOne<Session & { jira_issue_key: string | null }>(
|
|
`SELECT id, project, session_number, duration_minutes, jira_issue_key
|
|
FROM sessions WHERE id = $1`,
|
|
[session_id]
|
|
);
|
|
|
|
if (!session) {
|
|
return `Session ended: ${session_id}`;
|
|
}
|
|
|
|
// Post session output to Jira and close the session issue (non-blocking)
|
|
let jiraStatus = '';
|
|
if (session.jira_issue_key) {
|
|
try {
|
|
// Collect session output for Jira comment
|
|
const sessionOutput = await buildSessionOutput(session_id, session, summary);
|
|
|
|
// Post as comment
|
|
const commented = await addComment(session.jira_issue_key, sessionOutput);
|
|
|
|
// Update issue description with final summary
|
|
const descriptionUpdate = [
|
|
`## Session ${session.project} #${session.session_number}`,
|
|
`**Duration:** ${session.duration_minutes || 0} minutes`,
|
|
`**Status:** ${status}`,
|
|
`**Session ID:** ${session_id}`,
|
|
'',
|
|
`## Summary`,
|
|
summary,
|
|
].join('\n');
|
|
await updateIssueDescription(session.jira_issue_key, descriptionUpdate);
|
|
|
|
// Transition to Done
|
|
const transitioned = await transitionToDone(session.jira_issue_key);
|
|
|
|
jiraStatus = commented && transitioned
|
|
? ` [${session.jira_issue_key} → Done]`
|
|
: commented
|
|
? ` [${session.jira_issue_key} commented]`
|
|
: ` [${session.jira_issue_key} Jira update partial]`;
|
|
} catch (err) {
|
|
console.error('session-mcp: Failed to update session Jira issue:', err);
|
|
jiraStatus = ` [${session.jira_issue_key} Jira update failed]`;
|
|
}
|
|
}
|
|
|
|
return `Session ended: ${session.project} #${session.session_number} (${session.duration_minutes || 0}m)${jiraStatus}`;
|
|
}
|
|
|
|
/**
|
|
* Build full session output markdown for Jira comment.
|
|
*/
|
|
async function buildSessionOutput(
|
|
session_id: string,
|
|
session: { project: string | null; session_number: number | null; duration_minutes: number | null },
|
|
summary: string
|
|
): Promise<string> {
|
|
const lines: string[] = [];
|
|
lines.push(`# Session ${session.project} #${session.session_number}`);
|
|
lines.push(`Duration: ${session.duration_minutes || 0} minutes`);
|
|
lines.push('');
|
|
lines.push(`## Summary`);
|
|
lines.push(summary);
|
|
lines.push('');
|
|
|
|
// Get session 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]
|
|
);
|
|
|
|
if (notes.length > 0) {
|
|
const grouped: Record<string, string[]> = {};
|
|
for (const n of notes) {
|
|
if (!grouped[n.note_type]) grouped[n.note_type] = [];
|
|
grouped[n.note_type].push(n.content);
|
|
}
|
|
|
|
for (const [type, items] of Object.entries(grouped)) {
|
|
const label = type.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
lines.push(`## ${label}`);
|
|
for (const item of items) {
|
|
lines.push(`- ${item}`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
}
|
|
|
|
// Get commits
|
|
const commits = await query<{ commit_sha: string; repo: string; commit_message: string | null }>(
|
|
`SELECT commit_sha, repo, commit_message FROM session_commits WHERE session_id = $1 ORDER BY committed_at DESC`,
|
|
[session_id]
|
|
);
|
|
|
|
if (commits.length > 0) {
|
|
lines.push(`## Commits (${commits.length})`);
|
|
for (const c of commits) {
|
|
const msg = c.commit_message ? c.commit_message.split('\n')[0] : 'No message';
|
|
lines.push(`- ${c.commit_sha.substring(0, 7)} (${c.repo}): ${msg}`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* List sessions with filtering and pagination
|
|
*/
|
|
export async function sessionList(args: SessionListArgs): Promise<string> {
|
|
const { project, status, since, limit = 20 } = args;
|
|
|
|
let whereClause = 'WHERE 1=1';
|
|
const params: unknown[] = [];
|
|
let paramIndex = 1;
|
|
|
|
if (project) {
|
|
whereClause += ` AND project = $${paramIndex++}`;
|
|
params.push(project);
|
|
}
|
|
if (status) {
|
|
whereClause += ` AND status = $${paramIndex++}`;
|
|
params.push(status);
|
|
}
|
|
if (since) {
|
|
whereClause += ` AND started_at >= $${paramIndex++}::timestamp`;
|
|
params.push(since);
|
|
}
|
|
|
|
params.push(limit);
|
|
|
|
const sessions = await query<Session>(
|
|
`SELECT id, project, session_number, started_at, ended_at, duration_minutes,
|
|
summary, message_count, token_count, status
|
|
FROM sessions
|
|
${whereClause}
|
|
ORDER BY started_at DESC
|
|
LIMIT $${paramIndex}`,
|
|
params
|
|
);
|
|
|
|
if (sessions.length === 0) {
|
|
return `No sessions found${project ? ` for project ${project}` : ''}`;
|
|
}
|
|
|
|
const lines: string[] = [];
|
|
for (const s of sessions) {
|
|
const num = s.session_number ? `#${s.session_number}` : '';
|
|
const duration = s.duration_minutes ? `${s.duration_minutes}m` : 'active';
|
|
const messages = s.message_count ? `${s.message_count} msgs` : '';
|
|
const summaryPreview = s.summary ? s.summary.slice(0, 60) + '...' : 'No summary';
|
|
|
|
lines.push(`${s.project} ${num} (${duration}, ${messages}) - ${summaryPreview}`);
|
|
}
|
|
|
|
return `Sessions:\n${lines.join('\n')}`;
|
|
}
|
|
|
|
/**
|
|
* Semantic search across sessions using vector similarity
|
|
*/
|
|
export async function sessionSearch(args: SessionSearchArgs): Promise<string> {
|
|
const { query: searchQuery, project, 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];
|
|
|
|
if (project) {
|
|
whereClause += ` AND project = $3`;
|
|
params.splice(1, 0, project); // Insert before limit
|
|
params[2] = limit; // Adjust limit position
|
|
}
|
|
|
|
const sessions = await query<Session & { similarity: number }>(
|
|
`SELECT id, project, session_number, started_at, duration_minutes, summary,
|
|
1 - (embedding <=> $1) as similarity
|
|
FROM sessions
|
|
${whereClause}
|
|
ORDER BY embedding <=> $1
|
|
LIMIT $${project ? '3' : '2'}`,
|
|
params
|
|
);
|
|
|
|
if (sessions.length === 0) {
|
|
return 'No relevant sessions found';
|
|
}
|
|
|
|
const lines = ['Similar sessions:\n'];
|
|
for (const s of sessions) {
|
|
const sim = Math.round(s.similarity * 100);
|
|
const num = s.session_number ? `#${s.session_number}` : '';
|
|
const duration = s.duration_minutes ? `(${s.duration_minutes}m)` : '';
|
|
lines.push(`**${s.project} ${num}** ${duration} (${sim}% match)`);
|
|
lines.push(` ${s.summary || 'No summary'}`);
|
|
lines.push('');
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Get complete session context: tasks, commits, builds, memories
|
|
*/
|
|
export async function sessionContext(session_id: string): Promise<string> {
|
|
// Get session details
|
|
const session = await queryOne<Session>(
|
|
`SELECT * FROM sessions WHERE id = $1`,
|
|
[session_id]
|
|
);
|
|
|
|
if (!session) {
|
|
return `Session not found: ${session_id}`;
|
|
}
|
|
|
|
const lines: string[] = [];
|
|
lines.push(`**Session: ${session.project} #${session.session_number}**`);
|
|
lines.push(`Started: ${session.started_at}`);
|
|
if (session.ended_at) {
|
|
lines.push(`Ended: ${session.ended_at} (${session.duration_minutes}m)`);
|
|
}
|
|
if (session.summary) {
|
|
lines.push(`\nSummary: ${session.summary}`);
|
|
}
|
|
lines.push('');
|
|
|
|
// Get tasks touched in this session
|
|
const tasks = await query<{ task_id: string; title: string; status: string; activity_count: number }>(
|
|
`SELECT task_id, title, status, activity_count
|
|
FROM session_tasks
|
|
WHERE session_id = $1
|
|
ORDER BY first_touched`,
|
|
[session_id]
|
|
);
|
|
|
|
if (tasks.length > 0) {
|
|
lines.push(`**Tasks (${tasks.length}):**`);
|
|
for (const t of tasks) {
|
|
lines.push(`• ${t.task_id}: ${t.title} [${t.status}] (${t.activity_count} activities)`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
// Get commits made in this session
|
|
const commits = await query<{ commit_sha: string; repo: string; commit_message: string | null }>(
|
|
`SELECT commit_sha, repo, commit_message
|
|
FROM session_commits
|
|
WHERE session_id = $1
|
|
ORDER BY committed_at DESC`,
|
|
[session_id]
|
|
);
|
|
|
|
if (commits.length > 0) {
|
|
lines.push(`**Commits (${commits.length}):**`);
|
|
for (const c of commits) {
|
|
const msg = c.commit_message ? c.commit_message.split('\n')[0] : 'No message';
|
|
lines.push(`• ${c.commit_sha.substring(0, 7)} (${c.repo}): ${msg}`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
// Get builds linked to this session
|
|
const builds = await query<{ build_number: number; status: string; version_id: string | null }>(
|
|
`SELECT build_number, status, version_id
|
|
FROM builds
|
|
WHERE session_id = $1
|
|
ORDER BY started_at DESC`,
|
|
[session_id]
|
|
);
|
|
|
|
if (builds.length > 0) {
|
|
lines.push(`**Builds (${builds.length}):**`);
|
|
for (const b of builds) {
|
|
lines.push(`• Build #${b.build_number}: ${b.status}${b.version_id ? ` (${b.version_id})` : ''}`);
|
|
}
|
|
lines.push('');
|
|
}
|
|
|
|
// Get memories stored in this session
|
|
const memories = await query<{ category: string; title: string; content: string }>(
|
|
`SELECT category, title, content
|
|
FROM memories
|
|
WHERE session_id = $1
|
|
ORDER BY created_at`,
|
|
[session_id]
|
|
);
|
|
|
|
if (memories.length > 0) {
|
|
lines.push(`**Memories (${memories.length}):**`);
|
|
for (const m of memories) {
|
|
lines.push(`• [${m.category}] ${m.title}`);
|
|
lines.push(` ${m.content.slice(0, 100)}${m.content.length > 100 ? '...' : ''}`);
|
|
}
|
|
}
|
|
|
|
// Show metrics
|
|
if (session.message_count || session.token_count || session.tools_used) {
|
|
lines.push('\n**Metrics:**');
|
|
if (session.message_count) lines.push(`• Messages: ${session.message_count}`);
|
|
if (session.token_count) lines.push(`• Tokens: ${session.token_count}`);
|
|
if (session.tools_used && session.tools_used.length > 0) {
|
|
lines.push(`• Tools: ${session.tools_used.join(', ')}`);
|
|
}
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Record build information linked to session and version
|
|
*/
|
|
export async function buildRecord(
|
|
session_id: string | null,
|
|
version_id: string,
|
|
build_number: number,
|
|
git_commit_sha: string | null,
|
|
status: string,
|
|
started_at: string
|
|
): Promise<string> {
|
|
await execute(
|
|
`INSERT INTO builds (session_id, version_id, build_number, git_commit_sha, status, started_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
[session_id, version_id, build_number, git_commit_sha, status, started_at]
|
|
);
|
|
|
|
return `Build recorded: #${build_number} for ${version_id} (${status})`;
|
|
}
|
|
|
|
/**
|
|
* Link a commit to a session (automatically called when commits are made)
|
|
*/
|
|
export async function sessionCommitLink(
|
|
session_id: string,
|
|
commit_sha: string,
|
|
repo: string,
|
|
commit_message: string | null,
|
|
committed_at: string | null
|
|
): Promise<string> {
|
|
// Check if session exists
|
|
const sessionExists = await queryOne<{ exists: boolean }>(
|
|
'SELECT EXISTS(SELECT 1 FROM sessions WHERE id = $1) as exists',
|
|
[session_id]
|
|
);
|
|
|
|
// Auto-create session if missing (prevents FK constraint error)
|
|
if (!sessionExists?.exists) {
|
|
const timestamp = committed_at || new Date().toISOString();
|
|
await execute(
|
|
`INSERT INTO sessions (id, started_at, status)
|
|
VALUES ($1, $2, 'active')
|
|
ON CONFLICT (id) DO NOTHING`,
|
|
[session_id, timestamp]
|
|
);
|
|
}
|
|
|
|
// Now safely insert commit link
|
|
await execute(
|
|
`INSERT INTO session_commits (session_id, commit_sha, repo, commit_message, committed_at)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (session_id, commit_sha) DO NOTHING`,
|
|
[session_id, commit_sha, repo, commit_message, committed_at]
|
|
);
|
|
|
|
const prefix = sessionExists?.exists ? '' : '[auto-created session] ';
|
|
return `${prefix}Linked commit ${commit_sha.substring(0, 7)} to session ${session_id}`;
|
|
}
|
|
|
|
/**
|
|
* Recover abandoned/orphaned sessions (Part of CF-572)
|
|
* Detects sessions that have been active for >2 hours without activity
|
|
* Marks them as abandoned and recovers any associated temp file notes
|
|
*/
|
|
export async function sessionRecoverOrphaned(args: { project?: string }): Promise<string> {
|
|
const { project } = args;
|
|
|
|
// Find abandoned sessions (active for >2 hours)
|
|
let whereClause = "WHERE status = 'active' AND started_at < NOW() - INTERVAL '2 hours'";
|
|
const params: unknown[] = [];
|
|
|
|
if (project) {
|
|
whereClause += ` AND project = $${params.length + 1}`;
|
|
params.push(project);
|
|
}
|
|
|
|
const orphanedSessions = await query<{
|
|
id: string;
|
|
project: string | null;
|
|
session_number: number | null;
|
|
started_at: string;
|
|
working_directory: string | null;
|
|
}>(
|
|
`SELECT id, project, session_number, started_at, working_directory
|
|
FROM sessions
|
|
${whereClause}
|
|
ORDER BY started_at ASC`,
|
|
params
|
|
);
|
|
|
|
if (orphanedSessions.length === 0) {
|
|
return 'No orphaned sessions found';
|
|
}
|
|
|
|
const results: string[] = [];
|
|
let totalNotesRecovered = 0;
|
|
|
|
for (const session of orphanedSessions) {
|
|
try {
|
|
// Mark session as abandoned
|
|
await execute(
|
|
`UPDATE sessions
|
|
SET status = 'abandoned', ended_at = NOW(), updated_at = NOW()
|
|
WHERE id = $1`,
|
|
[session.id]
|
|
);
|
|
|
|
results.push(
|
|
`✓ Session ${session.project} #${session.session_number} marked as abandoned`
|
|
);
|
|
|
|
// Attempt to recover transcript first (CF-580)
|
|
let transcriptRecovered = false;
|
|
if (session.working_directory) {
|
|
// Construct projects path: ~/.claude/projects/{encoded-dir}/
|
|
// Encoding: / and . → - (Claude Code removes dots from usernames)
|
|
const home = process.env.HOME || '';
|
|
const encodedDir = session.working_directory.replace(/[/\.]/g, '-');
|
|
const projectsDir = `${home}/.claude/projects/${encodedDir}`;
|
|
|
|
try {
|
|
const fs = await import('fs');
|
|
const path = await import('path');
|
|
|
|
if (fs.default.existsSync(projectsDir)) {
|
|
// Find the most recently modified JSONL file
|
|
const files = fs.default.readdirSync(projectsDir);
|
|
const jsonlFiles = files
|
|
.filter(f => f.endsWith('.jsonl'))
|
|
.map(f => ({
|
|
name: f,
|
|
path: `${projectsDir}/${f}`,
|
|
mtime: fs.default.statSync(`${projectsDir}/${f}`).mtimeMs,
|
|
}))
|
|
.sort((a, b) => b.mtime - a.mtime);
|
|
|
|
if (jsonlFiles.length > 0) {
|
|
const latestFile = jsonlFiles[0];
|
|
const transcriptContent = fs.default.readFileSync(latestFile.path, 'utf-8');
|
|
const lineCount = transcriptContent.split('\n').filter(l => l.trim()).length;
|
|
|
|
// Update session with transcript
|
|
await execute(
|
|
`UPDATE sessions
|
|
SET transcript_jsonl = $1, transcript_ingested_at = NOW(), transcript_file_path = $2, updated_at = NOW()
|
|
WHERE id = $3`,
|
|
[transcriptContent, latestFile.path, session.id]
|
|
);
|
|
|
|
results.push(` → Recovered transcript (${lineCount} lines)`);
|
|
transcriptRecovered = true;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// Silently skip transcript recovery errors
|
|
}
|
|
}
|
|
|
|
// Note: Legacy fallback to notes.md removed (CF-580 Phase 3)
|
|
// All recovery now uses JSONL transcripts. If recovery failed, log warning.
|
|
if (!transcriptRecovered) {
|
|
results.push(` ⚠ No transcript found for recovery`);
|
|
}
|
|
} catch (err) {
|
|
results.push(`✗ Failed to mark session ${session.id} as abandoned: ${err}`);
|
|
}
|
|
}
|
|
|
|
return `Recovered ${orphanedSessions.length} orphaned session(s), ${totalNotesRecovered} notes:\n${results.join('\n')}`;
|
|
}
|
|
|
|
/**
|
|
* Recover notes from temp files for a specific session
|
|
* Called after orphan is detected (Part of CF-572)
|
|
*/
|
|
export async function sessionRecoverTempNotes(args: { session_id: string; temp_file_path: string }): Promise<string> {
|
|
const { session_id, temp_file_path } = args;
|
|
|
|
try {
|
|
// Import notes parser utility
|
|
const { recoverNotesFromTempFile } = await import('../utils/notes-parser.js');
|
|
|
|
const recovered = await recoverNotesFromTempFile(session_id, temp_file_path, 'recovered');
|
|
|
|
if (recovered === 0) {
|
|
return `No notes to recover from ${temp_file_path}`;
|
|
}
|
|
|
|
return `Recovered ${recovered} note(s) from temp file for session ${session_id}`;
|
|
} catch (err) {
|
|
return `Failed to recover temp notes: ${err}`;
|
|
}
|
|
}
|