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>
698 lines
20 KiB
TypeScript
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 [];
|
|
}
|