Advertisement

← kuato

file-based/search.ts

7.9 KB Download file
#!/usr/bin/env bun
/**
 * File-based session search - no database required
 *
 * Usage:
 *   bun run file-based/search.ts --query "email system" --days 7
 *   bun run file-based/search.ts --tools Edit,Bash --limit 10
 *   bun run file-based/search.ts --file-pattern "components/"
 *
 * Output: JSON array of matching sessions
 */

import { readdirSync, statSync } from 'fs';
import { join, basename } from 'path';
import { parseArgs } from 'util';
import { parseSessionFile, getSearchableText } from '../shared/parser.js';
import type { ParsedSession, SearchResult, SearchOptions } from '../shared/types.js';

// Default Claude Code sessions directory
const DEFAULT_SESSIONS_DIR =
  process.env.CLAUDE_SESSIONS_DIR ||
  join(process.env.HOME || '', '.claude', 'projects');

/**
 * Find all session directories
 */
function findSessionDirs(baseDir: string): string[] {
  try {
    return readdirSync(baseDir)
      .map((name) => join(baseDir, name))
      .filter((path) => {
        try {
          return statSync(path).isDirectory();
        } catch {
          return false;
        }
      });
  } catch {
    return [];
  }
}

/**
 * Find all JSONL files in a directory
 */
function findSessionFiles(dir: string, options: SearchOptions): string[] {
  const files: string[] = [];
  const now = new Date();

  // Calculate date cutoffs
  let since: Date | undefined = options.since;
  if (options.days && !since) {
    since = new Date(now.getTime() - options.days * 24 * 60 * 60 * 1000);
  }

  try {
    for (const file of readdirSync(dir)) {
      if (!file.endsWith('.jsonl')) continue;

      const filePath = join(dir, file);
      const stat = statSync(filePath);

      // Filter by modification time
      if (since && stat.mtime < since) continue;
      if (options.until && stat.mtime > options.until) continue;

      files.push(filePath);
    }
  } catch {
    // Directory not readable
  }

  return files;
}

/**
 * Score relevance of a session against search query
 */
function scoreRelevance(session: ParsedSession, query: string): number {
  if (!query) return 1;

  const queryLower = query.toLowerCase();
  const queryTerms = queryLower.split(/\s+/).filter(Boolean);
  let score = 0;
  const matchedOn: string[] = [];

  // User messages have highest weight
  for (const msg of session.userMessages) {
    const msgLower = msg.toLowerCase();
    for (const term of queryTerms) {
      if (msgLower.includes(term)) {
        score += 10;
        if (!matchedOn.includes('userMessages')) {
          matchedOn.push('userMessages');
        }
      }
    }
  }

  // Tools used have medium weight
  for (const tool of session.toolsUsed) {
    const toolLower = tool.toLowerCase();
    for (const term of queryTerms) {
      if (toolLower.includes(term)) {
        score += 3;
        if (!matchedOn.includes('toolsUsed')) {
          matchedOn.push('toolsUsed');
        }
      }
    }
  }

  // Files touched have medium weight
  for (const file of session.filesFromToolCalls) {
    const fileLower = file.toLowerCase();
    for (const term of queryTerms) {
      if (fileLower.includes(term)) {
        score += 3;
        if (!matchedOn.includes('filesFromToolCalls')) {
          matchedOn.push('filesFromToolCalls');
        }
      }
    }
  }

  return score;
}

/**
 * Check if session matches filter criteria
 */
function matchesFilters(session: ParsedSession, options: SearchOptions): boolean {
  // Filter by tools
  if (options.tools && options.tools.length > 0) {
    const hasMatchingTool = options.tools.some((tool) =>
      session.toolsUsed.some((t) => t.toLowerCase().includes(tool.toLowerCase()))
    );
    if (!hasMatchingTool) return false;
  }

  // Filter by file pattern
  if (options.filePattern) {
    const pattern = options.filePattern.toLowerCase();
    const hasMatchingFile = session.filesFromToolCalls.some((f) =>
      f.toLowerCase().includes(pattern)
    );
    if (!hasMatchingFile) return false;
  }

  return true;
}

/**
 * Search sessions across all project directories
 */
function searchSessions(
  baseDir: string,
  options: SearchOptions
): SearchResult[] {
  const results: SearchResult[] = [];
  const sessionDirs = findSessionDirs(baseDir);

  for (const dir of sessionDirs) {
    const files = findSessionFiles(dir, options);

    for (const filePath of files) {
      try {
        const session = parseSessionFile(filePath);
        if (!session) continue;

        // Skip empty sessions
        if (session.userMessages.length === 0) continue;

        // Apply filters
        if (!matchesFilters(session, options)) continue;

        // Score relevance if query provided
        const relevance = options.query
          ? scoreRelevance(session, options.query)
          : 1;

        // Skip zero-relevance matches when searching
        if (options.query && relevance === 0) continue;

        results.push({
          ...session,
          relevance,
          transcriptPath: filePath,
        });
      } catch {
        // Skip unparseable files
      }
    }
  }

  // Sort by relevance (desc), then by date (desc)
  results.sort((a, b) => {
    if (a.relevance !== b.relevance) {
      return (b.relevance || 0) - (a.relevance || 0);
    }
    return b.endedAt.getTime() - a.endedAt.getTime();
  });

  // Apply limit
  const limit = options.limit || 20;
  return results.slice(0, limit);
}

/**
 * Format results for output
 */
function formatResults(results: SearchResult[]): object[] {
  return results.map((r) => ({
    id: r.id,
    startedAt: r.startedAt.toISOString(),
    endedAt: r.endedAt.toISOString(),
    gitBranch: r.gitBranch,
    messageCount: r.messageCount,
    inputTokens: r.inputTokens,
    outputTokens: r.outputTokens,
    toolsUsed: r.toolsUsed,
    filesFromToolCalls: r.filesFromToolCalls,
    userMessages: r.userMessages,
    modelsUsed: r.modelsUsed,
    relevance: r.relevance,
    transcriptPath: r.transcriptPath,
  }));
}

// CLI entry point
async function main() {
  const { values } = parseArgs({
    options: {
      query: { type: 'string', short: 'q' },
      days: { type: 'string', short: 'd' },
      since: { type: 'string' },
      until: { type: 'string' },
      tools: { type: 'string', short: 't' },
      'file-pattern': { type: 'string', short: 'f' },
      limit: { type: 'string', short: 'l' },
      dir: { type: 'string' },
      help: { type: 'boolean', short: 'h' },
    },
    allowPositionals: true,
  });

  if (values.help) {
    console.log(`
Claude Code Session Search (File-based)

Usage:
  bun run search.ts [options]

Options:
  -q, --query <text>        Search sessions by text
  -d, --days <n>            Limit to last N days
  --since <date>            Sessions after this date (YYYY-MM-DD)
  --until <date>            Sessions before this date (YYYY-MM-DD)
  -t, --tools <list>        Filter by tools (comma-separated)
  -f, --file-pattern <pat>  Filter by file path pattern
  -l, --limit <n>           Max results (default: 20)
  --dir <path>              Sessions directory (default: ~/.claude/projects)
  -h, --help                Show this help

Examples:
  bun run search.ts --query "email filtering" --days 7
  bun run search.ts --tools Edit,Bash --limit 10
  bun run search.ts --file-pattern "components/"
`);
    process.exit(0);
  }

  const options: SearchOptions = {
    query: values.query,
    days: values.days ? parseInt(values.days, 10) : undefined,
    since: values.since ? new Date(values.since) : undefined,
    until: values.until ? new Date(values.until) : undefined,
    tools: values.tools ? values.tools.split(',') : undefined,
    filePattern: values['file-pattern'],
    limit: values.limit ? parseInt(values.limit, 10) : 20,
  };

  const baseDir = values.dir || DEFAULT_SESSIONS_DIR;
  const results = searchSessions(baseDir, options);
  const formatted = formatResults(results);

  console.log(JSON.stringify(formatted, null, 2));
}

main().catch(console.error);