Add comprehensive tests for query parsing and Reciprocal Rank Fusion

- Implemented tests for the QueryParser class, covering various identifier splitting methods (CamelCase, snake_case, kebab-case), OR expansion, and FTS5 operator preservation.
- Added parameterized tests to validate expected token outputs for different query formats.
- Created edge case tests to ensure robustness against unusual input scenarios.
- Developed tests for the Reciprocal Rank Fusion (RRF) algorithm, including score computation, weight handling, and result ranking across multiple sources.
- Included tests for normalization of BM25 scores and tagging search results with source metadata.
This commit is contained in:
catlog22
2025-12-16 10:20:19 +08:00
parent 35485bbbb1
commit 3da0ef2adb
39 changed files with 6171 additions and 240 deletions

View File

@@ -11,10 +11,14 @@ import { createHash } from 'crypto';
import { existsSync, mkdirSync, renameSync, rmSync, readdirSync } from 'fs';
// Environment variable override for custom storage location
const CCW_DATA_DIR = process.env.CCW_DATA_DIR;
// Made dynamic to support testing environments
export function getCCWHome(): string {
return process.env.CCW_DATA_DIR || join(homedir(), '.ccw');
}
// Base CCW home directory
export const CCW_HOME = CCW_DATA_DIR || join(homedir(), '.ccw');
// Base CCW home directory (deprecated - use getCCWHome() for dynamic access)
// Kept for backward compatibility but will use dynamic value in tests
export const CCW_HOME = getCCWHome();
/**
* Convert project path to a human-readable folder name
@@ -119,7 +123,7 @@ function detectHierarchyImpl(absolutePath: string): HierarchyInfo {
const currentId = pathToFolderName(absolutePath);
// Get all existing project directories
const projectsDir = join(CCW_HOME, 'projects');
const projectsDir = join(getCCWHome(), 'projects');
if (!existsSync(projectsDir)) {
return { currentId, parentId: null, relativePath: '' };
}
@@ -243,7 +247,7 @@ function migrateToHierarchical(legacyDir: string, targetDir: string): void {
* @param parentPath - Parent project path
*/
function migrateChildProjects(parentId: string, parentPath: string): void {
const projectsDir = join(CCW_HOME, 'projects');
const projectsDir = join(getCCWHome(), 'projects');
if (!existsSync(projectsDir)) return;
const absoluteParentPath = resolve(parentPath);
@@ -312,25 +316,25 @@ export function ensureStorageDir(dirPath: string): void {
*/
export const GlobalPaths = {
/** Root CCW home directory */
root: () => CCW_HOME,
root: () => getCCWHome(),
/** Config directory */
config: () => join(CCW_HOME, 'config'),
config: () => join(getCCWHome(), 'config'),
/** Global settings file */
settings: () => join(CCW_HOME, 'config', 'settings.json'),
settings: () => join(getCCWHome(), 'config', 'settings.json'),
/** Recent project paths file */
recentPaths: () => join(CCW_HOME, 'config', 'recent-paths.json'),
recentPaths: () => join(getCCWHome(), 'config', 'recent-paths.json'),
/** Databases directory */
databases: () => join(CCW_HOME, 'db'),
databases: () => join(getCCWHome(), 'db'),
/** MCP templates database */
mcpTemplates: () => join(CCW_HOME, 'db', 'mcp-templates.db'),
mcpTemplates: () => join(getCCWHome(), 'db', 'mcp-templates.db'),
/** Logs directory */
logs: () => join(CCW_HOME, 'logs'),
logs: () => join(getCCWHome(), 'logs'),
};
/**
@@ -370,7 +374,7 @@ export function getProjectPaths(projectPath: string): ProjectPaths {
if (hierarchy.parentId) {
// Has parent, use hierarchical structure
projectDir = join(CCW_HOME, 'projects', hierarchy.parentId);
projectDir = join(getCCWHome(), 'projects', hierarchy.parentId);
// Build subdirectory path from relative path
const segments = hierarchy.relativePath.split('/').filter(Boolean);
@@ -379,7 +383,7 @@ export function getProjectPaths(projectPath: string): ProjectPaths {
}
// Check if we need to migrate old flat data
const legacyDir = join(CCW_HOME, 'projects', hierarchy.currentId);
const legacyDir = join(getCCWHome(), 'projects', hierarchy.currentId);
if (existsSync(legacyDir)) {
try {
migrateToHierarchical(legacyDir, projectDir);
@@ -393,7 +397,7 @@ export function getProjectPaths(projectPath: string): ProjectPaths {
}
} else {
// No parent, use root-level storage
projectDir = join(CCW_HOME, 'projects', hierarchy.currentId);
projectDir = join(getCCWHome(), 'projects', hierarchy.currentId);
// Check if there are child projects that need migration
try {
@@ -424,7 +428,7 @@ export function getProjectPaths(projectPath: string): ProjectPaths {
* @returns Object with all project-specific paths
*/
export function getProjectPathsById(projectId: string): ProjectPaths {
const projectDir = join(CCW_HOME, 'projects', projectId);
const projectDir = join(getCCWHome(), 'projects', projectId);
return {
root: projectDir,
@@ -448,6 +452,87 @@ export const StoragePaths = {
projectById: getProjectPathsById,
};
/**
* Information about a child project in hierarchical structure
*/
export interface ChildProjectInfo {
/** Absolute path to the child project */
projectPath: string;
/** Relative path from parent project */
relativePath: string;
/** Project ID */
projectId: string;
/** Storage paths for this child project */
paths: ProjectPaths;
}
/**
* Recursively scan for child projects in hierarchical storage structure
* @param projectPath - Parent project path
* @returns Array of child project information
*/
export function scanChildProjects(projectPath: string): ChildProjectInfo[] {
const absolutePath = resolve(projectPath);
const parentId = getProjectId(absolutePath);
const parentStorageDir = join(getCCWHome(), 'projects', parentId);
// If parent storage doesn't exist, no children
if (!existsSync(parentStorageDir)) {
return [];
}
const children: ChildProjectInfo[] = [];
/**
* Recursively scan directory for project data directories
*/
function scanDirectory(dir: string, relativePath: string): void {
if (!existsSync(dir)) return;
try {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const fullPath = join(dir, entry.name);
const currentRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
// Check if this directory contains project data
const dataMarkers = ['cli-history', 'memory', 'cache', 'config'];
const hasData = dataMarkers.some(marker => existsSync(join(fullPath, marker)));
if (hasData) {
// This is a child project
const childProjectPath = join(absolutePath, currentRelPath.replace(/\//g, sep));
const childId = getProjectId(childProjectPath);
children.push({
projectPath: childProjectPath,
relativePath: currentRelPath,
projectId: childId,
paths: getProjectPaths(childProjectPath)
});
}
// Continue scanning subdirectories (skip data directories)
if (!dataMarkers.includes(entry.name)) {
scanDirectory(fullPath, currentRelPath);
}
}
} catch (error) {
// Ignore read errors
if (process.env.DEBUG) {
console.error(`[scanChildProjects] Failed to scan ${dir}:`, error);
}
}
}
scanDirectory(parentStorageDir, '');
return children;
}
/**
* Legacy storage paths (for backward compatibility detection)
*/
@@ -487,7 +572,7 @@ export function isLegacyStoragePresent(projectPath: string): boolean {
* Get CCW home directory (for external use)
*/
export function getCcwHome(): string {
return CCW_HOME;
return getCCWHome();
}
/**

View File

@@ -732,6 +732,215 @@ export function getMemoryStore(projectPath: string): MemoryStore {
return storeCache.get(cacheKey)!;
}
/**
* Get aggregated stats from parent and all child projects
* @param projectPath - Parent project path
* @returns Aggregated statistics from all projects
*/
export function getAggregatedStats(projectPath: string): {
entities: number;
prompts: number;
conversations: number;
total: number;
projects: Array<{ path: string; stats: { entities: number; prompts: number; conversations: number } }>;
} {
const { scanChildProjects } = require('../config/storage-paths.js');
const childProjects = scanChildProjects(projectPath);
const projectStats: Array<{ path: string; stats: { entities: number; prompts: number; conversations: number } }> = [];
let totalEntities = 0;
let totalPrompts = 0;
let totalConversations = 0;
// Get parent stats
try {
const parentStore = getMemoryStore(projectPath);
const db = (parentStore as any).db;
const entityCount = (db.prepare('SELECT COUNT(*) as count FROM entities').get() as { count: number }).count;
const promptCount = (db.prepare('SELECT COUNT(*) as count FROM prompt_history').get() as { count: number }).count;
const conversationCount = (db.prepare('SELECT COUNT(*) as count FROM conversations').get() as { count: number }).count;
projectStats.push({
path: projectPath,
stats: { entities: entityCount, prompts: promptCount, conversations: conversationCount }
});
totalEntities += entityCount;
totalPrompts += promptCount;
totalConversations += conversationCount;
} catch (error) {
if (process.env.DEBUG) {
console.error(`[Memory Store] Failed to get stats for parent ${projectPath}:`, error);
}
}
// Get child stats
for (const child of childProjects) {
try {
const childStore = getMemoryStore(child.projectPath);
const db = (childStore as any).db;
const entityCount = (db.prepare('SELECT COUNT(*) as count FROM entities').get() as { count: number }).count;
const promptCount = (db.prepare('SELECT COUNT(*) as count FROM prompt_history').get() as { count: number }).count;
const conversationCount = (db.prepare('SELECT COUNT(*) as count FROM conversations').get() as { count: number }).count;
projectStats.push({
path: child.relativePath,
stats: { entities: entityCount, prompts: promptCount, conversations: conversationCount }
});
totalEntities += entityCount;
totalPrompts += promptCount;
totalConversations += conversationCount;
} catch (error) {
if (process.env.DEBUG) {
console.error(`[Memory Store] Failed to get stats for child ${child.projectPath}:`, error);
}
}
}
return {
entities: totalEntities,
prompts: totalPrompts,
conversations: totalConversations,
total: totalEntities + totalPrompts + totalConversations,
projects: projectStats
};
}
/**
* Get aggregated entities from parent and all child projects
* @param projectPath - Parent project path
* @param options - Query options
* @returns Combined entities from all projects with source information
*/
export function getAggregatedEntities(
projectPath: string,
options: { type?: string; limit?: number; offset?: number } = {}
): Array<HotEntity & { sourceProject?: string }> {
const { scanChildProjects } = require('../config/storage-paths.js');
const childProjects = scanChildProjects(projectPath);
const limit = options.limit || 50;
const offset = options.offset || 0;
const allEntities: Array<HotEntity & { sourceProject?: string }> = [];
// Get parent entities - apply LIMIT at SQL level
try {
const parentStore = getMemoryStore(projectPath);
const db = (parentStore as any).db;
let query = 'SELECT * FROM entities';
const params: any[] = [];
if (options.type) {
query += ' WHERE type = ?';
params.push(options.type);
}
query += ' ORDER BY last_seen_at DESC LIMIT ?';
params.push(limit);
const stmt = db.prepare(query);
const parentEntities = stmt.all(...params) as Entity[];
allEntities.push(...parentEntities.map((e: Entity) => ({ ...e, stats: {} as EntityStats, sourceProject: projectPath })));
} catch (error) {
if (process.env.DEBUG) {
console.error(`[Memory Store] Failed to get entities for parent ${projectPath}:`, error);
}
}
// Get child entities - apply LIMIT to each child
for (const child of childProjects) {
try {
const childStore = getMemoryStore(child.projectPath);
const db = (childStore as any).db;
let query = 'SELECT * FROM entities';
const params: any[] = [];
if (options.type) {
query += ' WHERE type = ?';
params.push(options.type);
}
query += ' ORDER BY last_seen_at DESC LIMIT ?';
params.push(limit);
const stmt = db.prepare(query);
const childEntities = stmt.all(...params) as Entity[];
allEntities.push(...childEntities.map((e: Entity) => ({ ...e, stats: {} as EntityStats, sourceProject: child.relativePath })));
} catch (error) {
if (process.env.DEBUG) {
console.error(`[Memory Store] Failed to get entities for child ${child.projectPath}:`, error);
}
}
}
// Sort by last_seen_at and apply final limit with offset
allEntities.sort((a, b) => {
const aTime = a.last_seen_at ? new Date(a.last_seen_at).getTime() : 0;
const bTime = b.last_seen_at ? new Date(b.last_seen_at).getTime() : 0;
return bTime - aTime;
});
return allEntities.slice(offset, offset + limit);
}
/**
* Get aggregated prompts from parent and all child projects
* @param projectPath - Parent project path
* @param limit - Maximum number of prompts to return
* @returns Combined prompts from all projects with source information
*/
export function getAggregatedPrompts(
projectPath: string,
limit: number = 50
): Array<PromptHistory & { sourceProject?: string }> {
const { scanChildProjects } = require('../config/storage-paths.js');
const childProjects = scanChildProjects(projectPath);
const allPrompts: Array<PromptHistory & { sourceProject?: string }> = [];
// Get parent prompts - use direct SQL query with LIMIT
try {
const parentStore = getMemoryStore(projectPath);
const db = (parentStore as any).db;
const stmt = db.prepare('SELECT * FROM prompt_history ORDER BY timestamp DESC LIMIT ?');
const parentPrompts = stmt.all(limit) as PromptHistory[];
allPrompts.push(...parentPrompts.map((p: PromptHistory) => ({ ...p, sourceProject: projectPath })));
} catch (error) {
if (process.env.DEBUG) {
console.error(`[Memory Store] Failed to get prompts for parent ${projectPath}:`, error);
}
}
// Get child prompts - apply LIMIT to each child to reduce memory footprint
for (const child of childProjects) {
try {
const childStore = getMemoryStore(child.projectPath);
const db = (childStore as any).db;
const stmt = db.prepare('SELECT * FROM prompt_history ORDER BY timestamp DESC LIMIT ?');
const childPrompts = stmt.all(limit) as PromptHistory[];
allPrompts.push(...childPrompts.map((p: PromptHistory) => ({ ...p, sourceProject: child.relativePath })));
} catch (error) {
if (process.env.DEBUG) {
console.error(`[Memory Store] Failed to get prompts for child ${child.projectPath}:`, error);
}
}
}
// Sort by timestamp and apply final limit
allPrompts.sort((a, b) => {
const aTime = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const bTime = b.timestamp ? new Date(b.timestamp).getTime() : 0;
return bTime - aTime;
});
return allPrompts.slice(0, limit);
}
/**
* Close all store instances
*/

View File

@@ -212,7 +212,7 @@ export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
const status = url.searchParams.get('status') || null;
const category = url.searchParams.get('category') as 'user' | 'internal' | 'insight' | null;
const search = url.searchParams.get('search') || null;
const recursive = url.searchParams.get('recursive') !== 'false';
const recursive = url.searchParams.get('recursive') === 'true';
getExecutionHistoryAsync(projectPath, { limit, tool, status, category, search, recursive })
.then(history => {

View File

@@ -222,21 +222,30 @@ export async function handleMemoryRoutes(ctx: RouteContext): Promise<boolean> {
const projectPath = url.searchParams.get('path') || initialPath;
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
const search = url.searchParams.get('search') || null;
const recursive = url.searchParams.get('recursive') === 'true';
try {
const memoryStore = getMemoryStore(projectPath);
let prompts;
if (search) {
prompts = memoryStore.searchPrompts(search, limit);
// Recursive mode: aggregate prompts from parent and child projects
if (recursive && !search) {
const { getAggregatedPrompts } = await import('../memory-store.js');
prompts = getAggregatedPrompts(projectPath, limit);
} else {
// Get all recent prompts (we'll need to add this method to MemoryStore)
const stmt = memoryStore['db'].prepare(`
SELECT * FROM prompt_history
ORDER BY timestamp DESC
LIMIT ?
`);
prompts = stmt.all(limit);
// Non-recursive mode or search mode: query only current project
const memoryStore = getMemoryStore(projectPath);
if (search) {
prompts = memoryStore.searchPrompts(search, limit);
} else {
// Get all recent prompts (we'll need to add this method to MemoryStore)
const stmt = memoryStore['db'].prepare(`
SELECT * FROM prompt_history
ORDER BY timestamp DESC
LIMIT ?
`);
prompts = stmt.all(limit);
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -506,8 +515,23 @@ Return ONLY valid JSON in this exact format (no markdown, no code blocks, just p
const projectPath = url.searchParams.get('path') || initialPath;
const filter = url.searchParams.get('filter') || 'all'; // today, week, all
const limit = parseInt(url.searchParams.get('limit') || '10', 10);
const recursive = url.searchParams.get('recursive') === 'true';
try {
// If requesting aggregated stats, use the aggregated function
if (url.searchParams.has('aggregated') || recursive) {
const { getAggregatedStats } = await import('../memory-store.js');
const aggregatedStats = getAggregatedStats(projectPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
stats: aggregatedStats,
aggregated: true
}));
return true;
}
// Original hotspot statistics (non-recursive)
const memoryStore = getMemoryStore(projectPath);
const hotEntities = memoryStore.getHotEntities(limit * 4);

View File

@@ -1068,3 +1068,55 @@ async function updateCcwToolsMcp(scope = 'workspace') {
showRefreshToast(`Failed to update CCW Tools MCP: ${err.message}`, 'error');
}
}
// ========================================
// CCW Tools MCP for Codex
// ========================================
// Get selected tools from Codex checkboxes
function getSelectedCcwToolsCodex() {
const checkboxes = document.querySelectorAll('.ccw-tool-checkbox-codex:checked');
return Array.from(checkboxes).map(cb => cb.dataset.tool);
}
// Select tools by category for Codex
function selectCcwToolsCodex(type) {
const checkboxes = document.querySelectorAll('.ccw-tool-checkbox-codex');
const coreTools = ['write_file', 'edit_file', 'codex_lens', 'smart_search'];
checkboxes.forEach(cb => {
if (type === 'all') {
cb.checked = true;
} else if (type === 'none') {
cb.checked = false;
} else if (type === 'core') {
cb.checked = coreTools.includes(cb.dataset.tool);
}
});
}
// Install/Update CCW Tools MCP to Codex
async function installCcwToolsMcpToCodex() {
const selectedTools = getSelectedCcwToolsCodex();
if (selectedTools.length === 0) {
showRefreshToast('Please select at least one tool', 'warning');
return;
}
const ccwToolsConfig = buildCcwToolsConfig(selectedTools);
try {
const isUpdate = codexMcpServers && codexMcpServers['ccw-tools'];
const actionLabel = isUpdate ? 'Updating' : 'Installing';
showRefreshToast(`${actionLabel} CCW Tools MCP to Codex...`, 'info');
await addCodexMcpServer('ccw-tools', ccwToolsConfig);
const resultLabel = isUpdate ? 'updated in' : 'installed to';
showRefreshToast(`CCW Tools ${resultLabel} Codex (${selectedTools.length} tools)`, 'success');
} catch (err) {
console.error('Failed to install CCW Tools MCP to Codex:', err);
showRefreshToast(`Failed to install CCW Tools MCP to Codex: ${err.message}`, 'error');
}
}

View File

@@ -15,7 +15,7 @@ const CCW_MCP_TOOLS = [
{ name: 'cli_executor', desc: 'Gemini/Qwen/Codex CLI', core: false },
];
// Get currently enabled tools from installed config
// Get currently enabled tools from installed config (Claude)
function getCcwEnabledTools() {
const currentPath = projectPath; // Keep original format (forward slash)
const projectData = mcpAllProjects[currentPath] || {};
@@ -28,6 +28,18 @@ function getCcwEnabledTools() {
return CCW_MCP_TOOLS.filter(t => t.core).map(t => t.name);
}
// Get currently enabled tools from Codex config
function getCcwEnabledToolsCodex() {
const ccwConfig = codexMcpServers?.['ccw-tools'];
if (ccwConfig?.env?.CCW_ENABLED_TOOLS) {
const val = ccwConfig.env.CCW_ENABLED_TOOLS;
if (val.toLowerCase() === 'all') return CCW_MCP_TOOLS.map(t => t.name);
return val.split(',').map(t => t.trim());
}
// Default to core tools if not installed
return CCW_MCP_TOOLS.filter(t => t.core).map(t => t.name);
}
async function renderMcpManager() {
const container = document.getElementById('mainContent');
if (!container) return;
@@ -120,6 +132,7 @@ async function renderMcpManager() {
// Check if CCW Tools is already installed
const isCcwToolsInstalled = currentProjectServerNames.includes("ccw-tools");
const enabledTools = getCcwEnabledTools();
const enabledToolsCodex = getCcwEnabledToolsCodex();
// Prepare Codex servers data
const codexServerEntries = Object.entries(codexMcpServers || {});
@@ -157,6 +170,60 @@ async function renderMcpManager() {
</div>
${currentCliMode === 'codex' ? `
<!-- CCW Tools MCP Server Card (Codex mode) -->
<div class="mcp-section mb-6">
<div class="ccw-tools-card bg-gradient-to-br from-orange-500/10 to-orange-500/5 border-2 ${codexMcpServers && codexMcpServers['ccw-tools'] ? 'border-success' : 'border-orange-500/30'} rounded-lg p-6 hover:shadow-lg transition-all">
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 flex-1">
<div class="shrink-0 w-12 h-12 bg-orange-500 rounded-lg flex items-center justify-center">
<i data-lucide="wrench" class="w-6 h-6 text-white"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<h3 class="text-lg font-bold text-foreground">CCW Tools MCP</h3>
<span class="text-xs px-2 py-0.5 bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300 rounded-full">Codex</span>
${codexMcpServers && codexMcpServers['ccw-tools'] ? `
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-semibold rounded-full bg-success-light text-success">
<i data-lucide="check" class="w-3 h-3"></i>
${enabledToolsCodex.length} tools
</span>
` : `
<span class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-500/20 text-orange-600 dark:text-orange-400">
<i data-lucide="package" class="w-3 h-3"></i>
${t('mcp.available')}
</span>
`}
</div>
<p class="text-sm text-muted-foreground mb-3">${t('mcp.ccwToolsDesc')}</p>
<!-- Tool Selection Grid for Codex -->
<div class="grid grid-cols-3 sm:grid-cols-5 gap-2 mb-3">
${CCW_MCP_TOOLS.map(tool => `
<label class="flex items-center gap-1.5 text-xs cursor-pointer hover:bg-muted/50 rounded px-1.5 py-1 transition-colors">
<input type="checkbox" class="ccw-tool-checkbox-codex w-3 h-3"
data-tool="${tool.name}"
${enabledToolsCodex.includes(tool.name) ? 'checked' : ''}>
<span class="${tool.core ? 'font-medium' : 'text-muted-foreground'}">${tool.desc}</span>
</label>
`).join('')}
</div>
<div class="flex items-center gap-3 text-xs">
<button class="text-orange-500 hover:underline" onclick="selectCcwToolsCodex('core')">Core only</button>
<button class="text-orange-500 hover:underline" onclick="selectCcwToolsCodex('all')">All</button>
<button class="text-muted-foreground hover:underline" onclick="selectCcwToolsCodex('none')">None</button>
</div>
</div>
</div>
<div class="shrink-0">
<button class="px-4 py-2 text-sm bg-orange-500 text-white rounded-lg hover:opacity-90 transition-opacity flex items-center gap-1"
onclick="installCcwToolsMcpToCodex()">
<i data-lucide="download" class="w-4 h-4"></i>
${codexMcpServers && codexMcpServers['ccw-tools'] ? t('mcp.update') : t('mcp.install')}
</button>
</div>
</div>
</div>
</div>
<!-- Codex MCP Servers Section -->
<div class="mcp-section mb-6">
<div class="flex items-center justify-between mb-4">

View File

@@ -1128,33 +1128,61 @@ export async function getExecutionHistoryAsync(baseDir: string, options: {
}> {
const { limit = 50, tool = null, status = null, category = null, search = null, recursive = false } = options;
// With centralized storage, just query the current project
// recursive mode now searches all projects in centralized storage
// Recursive mode: aggregate data from parent and all child projects
if (recursive) {
const projectIds = findProjectsWithHistory();
const { scanChildProjects } = await import('../config/storage-paths.js');
const childProjects = scanChildProjects(baseDir);
let allExecutions: (HistoryIndex['executions'][0] & { sourceDir?: string })[] = [];
let totalCount = 0;
for (const projectId of projectIds) {
try {
// Use centralized path helper for project ID
const projectPaths = StoragePaths.projectById(projectId);
if (existsSync(projectPaths.historyDb)) {
// We need to use CliHistoryStore directly for arbitrary project IDs
const { CliHistoryStore } = await import('./cli-history-store.js');
// CliHistoryStore expects a project path, but we have project ID
// For now, skip cross-project queries - just query current project
}
} catch {
// Skip projects with errors
// Query parent project - apply limit at source to reduce memory footprint
try {
const parentStore = await getSqliteStore(baseDir);
const parentResult = parentStore.getHistory({ limit, tool, status, category, search });
totalCount += parentResult.total;
for (const exec of parentResult.executions) {
allExecutions.push({ ...exec, sourceDir: baseDir });
}
} catch (error) {
if (process.env.DEBUG) {
console.error(`[CLI History] Failed to query parent project ${baseDir}:`, error);
}
}
// For simplicity, just query current project in recursive mode too
const store = await getSqliteStore(baseDir);
return store.getHistory({ limit, tool, status, category, search });
// Query all child projects - apply limit to each child
for (const child of childProjects) {
try {
const childStore = await getSqliteStore(child.projectPath);
const childResult = childStore.getHistory({ limit, tool, status, category, search });
totalCount += childResult.total;
for (const exec of childResult.executions) {
allExecutions.push({
...exec,
sourceDir: child.relativePath // Show relative path for clarity
});
}
} catch (error) {
if (process.env.DEBUG) {
console.error(`[CLI History] Failed to query child project ${child.projectPath}:`, error);
}
}
}
// Sort by timestamp (newest first) and apply limit
allExecutions.sort((a, b) => Number(b.timestamp) - Number(a.timestamp));
const limitedExecutions = allExecutions.slice(0, limit);
return {
total: totalCount,
count: limitedExecutions.length,
executions: limitedExecutions
};
}
// Non-recursive mode: only query current project
const store = await getSqliteStore(baseDir);
return store.getHistory({ limit, tool, status, category, search });
}
@@ -1176,26 +1204,49 @@ export function getExecutionHistory(baseDir: string, options: {
try {
if (recursive) {
const projectDirs = findProjectsWithHistory();
const { scanChildProjects } = require('../config/storage-paths.js');
const childProjects = scanChildProjects(baseDir);
let allExecutions: (HistoryIndex['executions'][0] & { sourceDir?: string })[] = [];
let totalCount = 0;
for (const projectDir of projectDirs) {
try {
// Use baseDir as context for relative path display
const store = getSqliteStoreSync(baseDir);
const result = store.getHistory({ limit: 100, tool, status });
totalCount += result.total;
// Query parent project - apply limit at source
try {
const parentStore = getSqliteStoreSync(baseDir);
const parentResult = parentStore.getHistory({ limit, tool, status });
totalCount += parentResult.total;
for (const exec of result.executions) {
allExecutions.push({ ...exec, sourceDir: projectDir });
}
} catch {
// Skip projects with errors
for (const exec of parentResult.executions) {
allExecutions.push({ ...exec, sourceDir: baseDir });
}
} catch (error) {
if (process.env.DEBUG) {
console.error(`[CLI History Sync] Failed to query parent project ${baseDir}:`, error);
}
}
allExecutions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
// Query all child projects - apply limit to each child
for (const child of childProjects) {
try {
const childStore = getSqliteStoreSync(child.projectPath);
const childResult = childStore.getHistory({ limit, tool, status });
totalCount += childResult.total;
for (const exec of childResult.executions) {
allExecutions.push({
...exec,
sourceDir: child.relativePath
});
}
} catch (error) {
if (process.env.DEBUG) {
console.error(`[CLI History Sync] Failed to query child project ${child.projectPath}:`, error);
}
}
}
// Sort by timestamp (newest first) and apply limit
allExecutions.sort((a, b) => Number(b.timestamp) - Number(a.timestamp));
return {
total: totalCount,