feat: add Discuss and Explore subagents for dynamic critique and code exploration

- Implement Discuss Subagent for multi-perspective critique with dynamic perspectives.
- Create Explore Subagent for shared codebase exploration with centralized caching.
- Add tests for CcwToolsMcpCard component to ensure enabled tools are preserved on config save.
- Introduce SessionPreviewPanel component for previewing and selecting sessions for Memory V2 extraction.
- Develop CommandCreateDialog component for creating/importing commands with import and CLI generate modes.
This commit is contained in:
catlog22
2026-02-27 17:25:52 +08:00
parent 3db74cc7b0
commit 3b92bfae8c
45 changed files with 6508 additions and 128 deletions

View File

@@ -19,6 +19,7 @@ import {
GitBranch,
Send,
FileBarChart,
Settings,
} from 'lucide-react';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
@@ -31,7 +32,7 @@ import type { HookTriggerType } from './HookCard';
/**
* Template category type
*/
export type TemplateCategory = 'notification' | 'indexing' | 'automation';
export type TemplateCategory = 'notification' | 'indexing' | 'automation' | 'utility';
/**
* Hook template definition
@@ -226,6 +227,34 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
'-e',
'const cp=require("child_process");const payload=JSON.stringify({type:"MEMORY_V2_STATUS_UPDATED",project:process.env.CLAUDE_PROJECT_DIR||process.cwd(),timestamp:Date.now()});cp.spawnSync("curl",["-s","-X","POST","-H","Content-Type: application/json","-d",payload,"http://localhost:3456/api/hook"],{stdio:"inherit",shell:true})'
]
},
// --- Memory Operations ---
{
id: 'memory-auto-compress',
name: 'Auto Memory Compress',
description: 'Automatically compress memory when entries exceed threshold',
category: 'automation',
trigger: 'Stop',
command: 'ccw',
args: ['memory', 'consolidate', '--threshold', '50']
},
{
id: 'memory-preview-extract',
name: 'Memory Preview & Extract',
description: 'Preview extraction queue and extract eligible sessions',
category: 'automation',
trigger: 'SessionStart',
command: 'ccw',
args: ['memory', 'preview', '--include-native']
},
{
id: 'memory-status-check',
name: 'Memory Status Check',
description: 'Check memory extraction and consolidation status',
category: 'utility',
trigger: 'SessionStart',
command: 'ccw',
args: ['memory', 'status']
}
] as const;
@@ -234,7 +263,8 @@ export const HOOK_TEMPLATES: readonly HookTemplate[] = [
const CATEGORY_ICONS: Record<TemplateCategory, { icon: typeof Bell; color: string; bg: string }> = {
notification: { icon: Bell, color: 'text-blue-500', bg: 'bg-blue-500/10' },
indexing: { icon: Database, color: 'text-purple-500', bg: 'bg-purple-500/10' },
automation: { icon: Wrench, color: 'text-orange-500', bg: 'bg-orange-500/10' }
automation: { icon: Wrench, color: 'text-orange-500', bg: 'bg-orange-500/10' },
utility: { icon: Settings, color: 'text-gray-500', bg: 'bg-gray-500/10' }
};
// ========== Template Icons ==========
@@ -258,7 +288,8 @@ function getCategoryName(category: TemplateCategory, formatMessage: ReturnType<t
const names: Record<TemplateCategory, string> = {
notification: formatMessage({ id: 'cliHooks.templates.categories.notification' }),
indexing: formatMessage({ id: 'cliHooks.templates.categories.indexing' }),
automation: formatMessage({ id: 'cliHooks.templates.categories.automation' })
automation: formatMessage({ id: 'cliHooks.templates.categories.automation' }),
utility: formatMessage({ id: 'cliHooks.templates.categories.utility' })
};
return names[category];
}
@@ -352,7 +383,9 @@ export function HookQuickTemplates({
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-medium text-foreground leading-tight">
{formatMessage({ id: `cliHooks.templates.templates.${template.id}.name` })}
{formatMessage(
{ id: `cliHooks.templates.templates.${template.id}.name`, defaultMessage: template.name }
)}
</h4>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
@@ -394,7 +427,9 @@ export function HookQuickTemplates({
{/* Description */}
<p className="text-xs text-muted-foreground leading-relaxed flex-1 pl-11">
{formatMessage({ id: `cliHooks.templates.templates.${template.id}.description` })}
{formatMessage(
{ id: `cliHooks.templates.templates.${template.id}.description`, defaultMessage: template.description }
)}
</p>
</Card>
);

View File

@@ -0,0 +1,102 @@
// ========================================
// CcwToolsMcpCard Component Tests
// ========================================
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@/test/i18n';
import userEvent from '@testing-library/user-event';
import { CcwToolsMcpCard } from './CcwToolsMcpCard';
import { updateCcwConfig, updateCcwConfigForCodex } from '@/lib/api';
vi.mock('@/lib/api', () => ({
installCcwMcp: vi.fn(),
uninstallCcwMcp: vi.fn(),
updateCcwConfig: vi.fn(),
installCcwMcpToCodex: vi.fn(),
uninstallCcwMcpFromCodex: vi.fn(),
updateCcwConfigForCodex: vi.fn(),
}));
vi.mock('@/hooks/useNotifications', () => ({
useNotifications: () => ({
success: vi.fn(),
error: vi.fn(),
}),
}));
describe('CcwToolsMcpCard', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('preserves enabledTools when saving config (Codex)', async () => {
const updateCodexMock = vi.mocked(updateCcwConfigForCodex);
updateCodexMock.mockResolvedValue({
isInstalled: true,
enabledTools: [],
installedScopes: ['global'],
});
render(
<CcwToolsMcpCard
target="codex"
isInstalled={true}
enabledTools={['write_file', 'read_many_files']}
onToggleTool={vi.fn()}
onUpdateConfig={vi.fn()}
onInstall={vi.fn()}
/>,
{ locale: 'en' }
);
const user = userEvent.setup();
await user.click(screen.getByText(/CCW MCP Server|mcp\.ccw\.title/i));
await user.click(
screen.getByRole('button', { name: /Save Configuration|mcp\.ccw\.actions\.saveConfig/i })
);
await waitFor(() => {
expect(updateCodexMock).toHaveBeenCalledWith(
expect.objectContaining({
enabledTools: ['write_file', 'read_many_files'],
})
);
});
});
it('preserves enabledTools when saving config (Claude)', async () => {
const updateClaudeMock = vi.mocked(updateCcwConfig);
updateClaudeMock.mockResolvedValue({
isInstalled: true,
enabledTools: [],
installedScopes: ['global'],
});
render(
<CcwToolsMcpCard
isInstalled={true}
enabledTools={['write_file', 'smart_search']}
onToggleTool={vi.fn()}
onUpdateConfig={vi.fn()}
onInstall={vi.fn()}
/>,
{ locale: 'en' }
);
const user = userEvent.setup();
await user.click(screen.getByText(/CCW MCP Server|mcp\.ccw\.title/i));
await user.click(
screen.getByRole('button', { name: /Save Configuration|mcp\.ccw\.actions\.saveConfig/i })
);
await waitFor(() => {
expect(updateClaudeMock).toHaveBeenCalledWith(
expect.objectContaining({
enabledTools: ['write_file', 'smart_search'],
})
);
});
});
});

View File

@@ -37,7 +37,7 @@ import {
uninstallCcwMcpFromCodex,
updateCcwConfigForCodex,
} from '@/lib/api';
import { mcpServersKeys } from '@/hooks';
import { mcpServersKeys, useNotifications } from '@/hooks';
import { useQueryClient } from '@tanstack/react-query';
import { cn } from '@/lib/utils';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
@@ -128,6 +128,7 @@ export function CcwToolsMcpCard({
}: CcwToolsMcpCardProps) {
const { formatMessage } = useIntl();
const queryClient = useQueryClient();
const { success: notifySuccess, error: notifyError } = useNotifications();
const currentProjectPath = useWorkflowStore(selectProjectPath);
// Local state for config inputs
@@ -179,9 +180,19 @@ export function CcwToolsMcpCard({
onSuccess: () => {
if (isCodex) {
queryClient.invalidateQueries({ queryKey: ['codexMcpServers'] });
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfigCodex'] });
} else {
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
}
notifySuccess(formatMessage({ id: 'mcp.ccw.feedback.saveSuccess' }));
},
onError: (error) => {
console.error('Failed to update CCW config:', error);
notifyError(
formatMessage({ id: 'mcp.ccw.feedback.saveError' }),
error instanceof Error ? error.message : String(error)
);
},
});
@@ -201,6 +212,9 @@ export function CcwToolsMcpCard({
const handleConfigSave = () => {
updateConfigMutation.mutate({
// Preserve current tool selection; otherwise updateCcwConfig* falls back to defaults
// and can unintentionally overwrite user-chosen enabled tools.
enabledTools,
projectRoot: projectRootInput || undefined,
allowedDirs: allowedDirsInput || undefined,
enableSandbox: enableSandboxInput,

View File

@@ -0,0 +1,332 @@
// ========================================
// SessionPreviewPanel Component
// ========================================
// Preview and select sessions for Memory V2 extraction
import { useState, useMemo } from 'react';
import { useIntl } from 'react-intl';
import { formatDistanceToNow } from 'date-fns';
import { Search, Eye, Loader2, CheckCircle2, XCircle, Clock } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Input } from '@/components/ui/Input';
import { Checkbox } from '@/components/ui/Checkbox';
import {
usePreviewSessions,
useTriggerSelectiveExtraction,
} from '@/hooks/useMemoryV2';
import { cn } from '@/lib/utils';
interface SessionPreviewPanelProps {
onClose?: () => void;
onExtractComplete?: () => void;
}
// Helper function to format bytes
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
// Helper function to format timestamp
function formatTimestamp(timestamp: number): string {
try {
const date = new Date(timestamp);
return formatDistanceToNow(date, { addSuffix: true });
} catch {
return '-';
}
}
export function SessionPreviewPanel({ onClose, onExtractComplete }: SessionPreviewPanelProps) {
const intl = useIntl();
const [searchQuery, setSearchQuery] = useState('');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [includeNative, setIncludeNative] = useState(false);
const { data, isLoading, refetch } = usePreviewSessions(includeNative);
const triggerExtraction = useTriggerSelectiveExtraction();
// Filter sessions based on search query
const filteredSessions = useMemo(() => {
if (!data?.sessions) return [];
if (!searchQuery.trim()) return data.sessions;
const query = searchQuery.toLowerCase();
return data.sessions.filter(
(session) =>
session.sessionId.toLowerCase().includes(query) ||
session.tool.toLowerCase().includes(query) ||
session.source.toLowerCase().includes(query)
);
}, [data?.sessions, searchQuery]);
// Get ready sessions (eligible and not extracted)
const readySessions = useMemo(() => {
return filteredSessions.filter((s) => s.eligible && !s.extracted);
}, [filteredSessions]);
// Toggle session selection
const toggleSelection = (sessionId: string) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (next.has(sessionId)) {
next.delete(sessionId);
} else {
next.add(sessionId);
}
return next;
});
};
// Select all ready sessions
const selectAll = () => {
setSelectedIds(new Set(readySessions.map((s) => s.sessionId)));
};
// Clear selection
const selectNone = () => {
setSelectedIds(new Set());
};
// Trigger extraction for selected sessions
const handleExtract = async () => {
if (selectedIds.size === 0) return;
triggerExtraction.mutate(
{
sessionIds: Array.from(selectedIds),
includeNative,
},
{
onSuccess: () => {
setSelectedIds(new Set());
onExtractComplete?.();
},
}
);
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Eye className="w-5 h-5" />
{intl.formatMessage({ id: 'memory.v2.preview.title', defaultMessage: 'Extraction Queue Preview' })}
</h2>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox
checked={includeNative}
onCheckedChange={(checked) => setIncludeNative(checked === true)}
/>
{intl.formatMessage({ id: 'memory.v2.preview.includeNative', defaultMessage: 'Include Native Sessions' })}
</label>
<Button variant="outline" size="sm" onClick={() => refetch()}>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
'Refresh'
)}
</Button>
</div>
</div>
{/* Summary Bar */}
{data?.summary && (
<div className="grid grid-cols-4 gap-2 mb-4">
<div className="text-center p-2 bg-muted rounded">
<div className="text-lg font-bold">{data.summary.total}</div>
<div className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.preview.total', defaultMessage: 'Total' })}
</div>
</div>
<div className="text-center p-2 bg-muted rounded">
<div className="text-lg font-bold text-blue-600">{data.summary.eligible}</div>
<div className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.preview.eligible', defaultMessage: 'Eligible' })}
</div>
</div>
<div className="text-center p-2 bg-muted rounded">
<div className="text-lg font-bold text-green-600">{data.summary.alreadyExtracted}</div>
<div className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.preview.extracted', defaultMessage: 'Already Extracted' })}
</div>
</div>
<div className="text-center p-2 bg-muted rounded">
<div className="text-lg font-bold text-amber-600">{data.summary.readyForExtraction}</div>
<div className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.preview.ready', defaultMessage: 'Ready' })}
</div>
</div>
</div>
)}
{/* Search and Actions */}
<div className="flex items-center gap-2 mb-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder={intl.formatMessage({
id: 'memory.v2.preview.selectSessions',
defaultMessage: 'Search sessions...',
})}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<Button variant="outline" size="sm" onClick={selectAll}>
{intl.formatMessage({ id: 'memory.v2.preview.selectAll', defaultMessage: 'Select All' })}
</Button>
<Button variant="outline" size="sm" onClick={selectNone}>
{intl.formatMessage({ id: 'memory.v2.preview.selectNone', defaultMessage: 'Select None' })}
</Button>
</div>
{/* Session Table */}
<div className="flex-1 overflow-auto border rounded-lg">
{isLoading ? (
<div className="flex items-center justify-center h-48">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : filteredSessions.length === 0 ? (
<div className="flex items-center justify-center h-48 text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.preview.noSessions', defaultMessage: 'No sessions found' })}
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="w-10 p-2"></th>
<th className="text-left p-2">Source</th>
<th className="text-left p-2">Session ID</th>
<th className="text-left p-2">Tool</th>
<th className="text-left p-2">Timestamp</th>
<th className="text-right p-2">Size</th>
<th className="text-right p-2">Turns</th>
<th className="text-center p-2">Status</th>
</tr>
</thead>
<tbody>
{filteredSessions.map((session) => {
const isReady = session.eligible && !session.extracted;
const isSelected = selectedIds.has(session.sessionId);
const isDisabled = !isReady;
return (
<tr
key={session.sessionId}
className={cn(
'border-b hover:bg-muted/50 transition-colors',
isDisabled && 'opacity-60',
isSelected && 'bg-blue-50 dark:bg-blue-950/20'
)}
>
<td className="p-2">
<Checkbox
checked={isSelected}
disabled={isDisabled}
onCheckedChange={() => toggleSelection(session.sessionId)}
/>
</td>
<td className="p-2">
<Badge
variant="outline"
className={cn(
session.source === 'ccw'
? 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
: 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300'
)}
>
{session.source === 'ccw'
? intl.formatMessage({ id: 'memory.v2.preview.sourceCcw', defaultMessage: 'CCW' })
: intl.formatMessage({ id: 'memory.v2.preview.sourceNative', defaultMessage: 'Native' })}
</Badge>
</td>
<td className="p-2 font-mono text-xs truncate max-w-[150px]" title={session.sessionId}>
{session.sessionId}
</td>
<td className="p-2 truncate max-w-[100px]" title={session.tool}>
{session.tool || '-'}
</td>
<td className="p-2 text-muted-foreground">
{formatTimestamp(session.timestamp)}
</td>
<td className="p-2 text-right font-mono text-xs">
{formatBytes(session.bytes)}
</td>
<td className="p-2 text-right">
{session.turns}
</td>
<td className="p-2 text-center">
{session.extracted ? (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
<CheckCircle2 className="w-3 h-3 mr-1" />
{intl.formatMessage({ id: 'memory.v2.preview.extracted', defaultMessage: 'Extracted' })}
</Badge>
) : session.eligible ? (
<Badge className="bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
<Clock className="w-3 h-3 mr-1" />
{intl.formatMessage({ id: 'memory.v2.preview.ready', defaultMessage: 'Ready' })}
</Badge>
) : (
<Badge className="bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300">
<XCircle className="w-3 h-3 mr-1" />
{intl.formatMessage({ id: 'memory.v2.preview.ineligible', defaultMessage: 'Ineligible' })}
</Badge>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)}
</div>
{/* Footer Actions */}
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<div className="text-sm text-muted-foreground">
{selectedIds.size > 0 ? (
intl.formatMessage(
{ id: 'memory.v2.preview.selected', defaultMessage: '{count} sessions selected' },
{ count: selectedIds.size }
)
) : (
intl.formatMessage({ id: 'memory.v2.preview.selectHint', defaultMessage: 'Select sessions to extract' })
)}
</div>
<div className="flex items-center gap-2">
{onClose && (
<Button variant="outline" onClick={onClose}>
{intl.formatMessage({ id: 'common.close', defaultMessage: 'Close' })}
</Button>
)}
<Button
onClick={handleExtract}
disabled={selectedIds.size === 0 || triggerExtraction.isPending}
>
{triggerExtraction.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{intl.formatMessage({ id: 'memory.v2.extraction.extracting', defaultMessage: 'Extracting...' })}
</>
) : (
intl.formatMessage(
{ id: 'memory.v2.preview.extractSelected', defaultMessage: 'Extract Selected ({count})' },
{ count: selectedIds.size }
)
)}
</Button>
</div>
</div>
</div>
);
}
export default SessionPreviewPanel;

View File

@@ -28,6 +28,7 @@ import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
import { SessionPreviewPanel } from '@/components/memory/SessionPreviewPanel';
import 'highlight.js/styles/github-dark.css';
import {
useExtractionStatus,
@@ -84,6 +85,7 @@ function ExtractionCard() {
const { data: status, isLoading, refetch } = useExtractionStatus();
const trigger = useTriggerExtraction();
const [maxSessions, setMaxSessions] = useState(10);
const [showPreview, setShowPreview] = useState(false);
const handleTrigger = () => {
trigger.mutate(maxSessions);
@@ -94,83 +96,107 @@ function ExtractionCard() {
const lastRunText = formatRelativeTime(status?.lastRun);
return (
<Card className="p-4">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-medium flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-500" />
Phase 1: {intl.formatMessage({ id: 'memory.v2.extraction.title', defaultMessage: 'Extraction' })}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{intl.formatMessage({ id: 'memory.v2.extraction.description', defaultMessage: 'Extract structured memories from CLI sessions' })}
</p>
{lastRunText && (
<p className="text-xs text-muted-foreground mt-1">
{intl.formatMessage({ id: 'memory.v2.extraction.lastRun', defaultMessage: 'Last run' })}: {lastRunText}
<>
<Card className="p-4">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="font-medium flex items-center gap-2">
<Zap className="w-5 h-5 text-yellow-500" />
Phase 1: {intl.formatMessage({ id: 'memory.v2.extraction.title', defaultMessage: 'Extraction' })}
</h3>
<p className="text-sm text-muted-foreground mt-1">
{intl.formatMessage({ id: 'memory.v2.extraction.description', defaultMessage: 'Extract structured memories from CLI sessions' })}
</p>
{lastRunText && (
<p className="text-xs text-muted-foreground mt-1">
{intl.formatMessage({ id: 'memory.v2.extraction.lastRun', defaultMessage: 'Last run' })}: {lastRunText}
</p>
)}
</div>
{status && (
<div className="text-right">
<div className="text-2xl font-bold">{status.total_stage1}</div>
<div className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.extraction.extracted', defaultMessage: 'Extracted' })}
</div>
</div>
)}
</div>
{status && (
<div className="text-right">
<div className="text-2xl font-bold">{status.total_stage1}</div>
<div className="text-xs text-muted-foreground">
{intl.formatMessage({ id: 'memory.v2.extraction.extracted', defaultMessage: 'Extracted' })}
<div className="flex items-center gap-2 mb-4">
<input
type="number"
value={maxSessions}
onChange={(e) => setMaxSessions(Math.max(1, parseInt(e.target.value) || 10))}
className="w-20 px-2 py-1 text-sm border rounded bg-background"
min={1}
max={64}
/>
<span className="text-sm text-muted-foreground">sessions max</span>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleTrigger}
disabled={trigger.isPending || hasRunningJob}
size="sm"
>
{trigger.isPending || hasRunningJob ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{intl.formatMessage({ id: 'memory.v2.extraction.extracting', defaultMessage: 'Extracting...' })}
</>
) : (
<>
<Play className="w-4 h-4 mr-1" />
{intl.formatMessage({ id: 'memory.v2.extraction.trigger', defaultMessage: 'Trigger Extraction' })}
</>
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowPreview(true)}
title={intl.formatMessage({ id: 'memory.v2.preview.previewQueue', defaultMessage: 'Preview Queue' })}
>
<Eye className="w-4 h-4 mr-1" />
{intl.formatMessage({ id: 'memory.v2.preview.previewQueue', defaultMessage: 'Preview Queue' })}
</Button>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
</Button>
</div>
{status?.jobs && status.jobs.length > 0 && (
<div className="mt-4 pt-4 border-t">
<div className="text-xs text-muted-foreground mb-2">
{intl.formatMessage({ id: 'memory.v2.extraction.recentJobs', defaultMessage: 'Recent Jobs' })}
</div>
<div className="space-y-1 max-h-32 overflow-y-auto">
{status.jobs.slice(0, 5).map((job) => (
<div key={job.job_key} className="flex items-center justify-between text-sm">
<span className="font-mono text-xs truncate max-w-[150px]">{job.job_key}</span>
<StatusBadge status={job.status} />
</div>
))}
</div>
</div>
)}
</div>
</Card>
<div className="flex items-center gap-2 mb-4">
<input
type="number"
value={maxSessions}
onChange={(e) => setMaxSessions(Math.max(1, parseInt(e.target.value) || 10))}
className="w-20 px-2 py-1 text-sm border rounded bg-background"
min={1}
max={64}
/>
<span className="text-sm text-muted-foreground">sessions max</span>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleTrigger}
disabled={trigger.isPending || hasRunningJob}
size="sm"
>
{trigger.isPending || hasRunningJob ? (
<>
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
{intl.formatMessage({ id: 'memory.v2.extraction.extracting', defaultMessage: 'Extracting...' })}
</>
) : (
<>
<Play className="w-4 h-4 mr-1" />
{intl.formatMessage({ id: 'memory.v2.extraction.trigger', defaultMessage: 'Trigger Extraction' })}
</>
)}
</Button>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
</Button>
</div>
{status?.jobs && status.jobs.length > 0 && (
<div className="mt-4 pt-4 border-t">
<div className="text-xs text-muted-foreground mb-2">
{intl.formatMessage({ id: 'memory.v2.extraction.recentJobs', defaultMessage: 'Recent Jobs' })}
</div>
<div className="space-y-1 max-h-32 overflow-y-auto">
{status.jobs.slice(0, 5).map((job) => (
<div key={job.job_key} className="flex items-center justify-between text-sm">
<span className="font-mono text-xs truncate max-w-[150px]">{job.job_key}</span>
<StatusBadge status={job.status} />
</div>
))}
</div>
</div>
)}
</Card>
{/* Preview Queue Dialog */}
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-w-4xl max-h-[80vh]">
<SessionPreviewPanel
onClose={() => setShowPreview(false)}
onExtractComplete={() => {
setShowPreview(false);
refetch();
}}
/>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,408 @@
// ========================================
// Command Create Dialog Component
// ========================================
// Modal dialog for creating/importing commands with two modes:
// - Import: import existing command file
// - CLI Generate: AI-generated command from description
import { useState, useCallback } from 'react';
import { useIntl } from 'react-intl';
import {
Folder,
User,
FileCode,
Sparkles,
CheckCircle,
XCircle,
Loader2,
Info,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
} from '@/components/ui/Dialog';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Textarea } from '@/components/ui/Textarea';
import { Label } from '@/components/ui/Label';
import { validateCommandImport, createCommand } from '@/lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
import { cn } from '@/lib/utils';
export interface CommandCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: () => void;
cliType?: 'claude' | 'codex';
}
type CreateMode = 'import' | 'cli-generate';
type CommandLocation = 'project' | 'user';
interface ValidationResult {
valid: boolean;
errors?: string[];
commandInfo?: { name: string; description: string; usage?: string };
}
export function CommandCreateDialog({ open, onOpenChange, onCreated, cliType = 'claude' }: CommandCreateDialogProps) {
const { formatMessage } = useIntl();
const projectPath = useWorkflowStore(selectProjectPath);
const [mode, setMode] = useState<CreateMode>('import');
const [location, setLocation] = useState<CommandLocation>('project');
// Import mode state
const [sourcePath, setSourcePath] = useState('');
const [customName, setCustomName] = useState('');
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
const [isValidating, setIsValidating] = useState(false);
// CLI Generate mode state
const [commandName, setCommandName] = useState('');
const [description, setDescription] = useState('');
const [isCreating, setIsCreating] = useState(false);
const resetState = useCallback(() => {
setMode('import');
setLocation('project');
setSourcePath('');
setCustomName('');
setValidationResult(null);
setIsValidating(false);
setCommandName('');
setDescription('');
setIsCreating(false);
}, []);
const handleOpenChange = useCallback((open: boolean) => {
if (!open) {
resetState();
}
onOpenChange(open);
}, [onOpenChange, resetState]);
const handleValidate = useCallback(async () => {
if (!sourcePath.trim()) return;
setIsValidating(true);
setValidationResult(null);
try {
const result = await validateCommandImport(sourcePath.trim());
setValidationResult(result);
} catch (err) {
setValidationResult({
valid: false,
errors: [err instanceof Error ? err.message : String(err)],
});
} finally {
setIsValidating(false);
}
}, [sourcePath]);
const handleCreate = useCallback(async () => {
if (mode === 'import') {
if (!sourcePath.trim()) return;
if (!validationResult?.valid) return;
} else {
if (!commandName.trim()) return;
if (!description.trim()) return;
}
setIsCreating(true);
try {
await createCommand({
mode,
location,
sourcePath: mode === 'import' ? sourcePath.trim() : undefined,
commandName: mode === 'import' ? (customName.trim() || undefined) : commandName.trim(),
description: mode === 'cli-generate' ? description.trim() : undefined,
generationType: mode === 'cli-generate' ? 'description' : undefined,
projectPath,
cliType,
});
handleOpenChange(false);
onCreated();
} catch (err) {
console.error('Failed to create command:', err);
if (mode === 'import') {
setValidationResult({
valid: false,
errors: [err instanceof Error ? err.message : formatMessage({ id: 'commands.create.createError' })],
});
}
} finally {
setIsCreating(false);
}
}, [mode, location, sourcePath, customName, commandName, description, validationResult, projectPath, handleOpenChange, onCreated, formatMessage]);
const canCreate = mode === 'import'
? sourcePath.trim() && validationResult?.valid && !isCreating
: commandName.trim() && description.trim() && !isCreating;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{formatMessage({ id: 'commands.create.title' })}</DialogTitle>
<DialogDescription>
{formatMessage({ id: 'commands.description' })}
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
{/* Location Selection */}
<div className="space-y-2">
<Label>{formatMessage({ id: 'commands.create.location' })}</Label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
className={cn(
'px-4 py-3 text-left border-2 rounded-lg transition-all',
location === 'project'
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
)}
onClick={() => setLocation('project')}
>
<div className="flex items-center gap-2">
<Folder className="w-5 h-5" />
<div>
<div className="font-medium text-sm">{formatMessage({ id: 'commands.create.locationProject' })}</div>
<div className="text-xs text-muted-foreground">{`.${cliType}/commands/`}</div>
</div>
</div>
</button>
<button
type="button"
className={cn(
'px-4 py-3 text-left border-2 rounded-lg transition-all',
location === 'user'
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
)}
onClick={() => setLocation('user')}
>
<div className="flex items-center gap-2">
<User className="w-5 h-5" />
<div>
<div className="font-medium text-sm">{formatMessage({ id: 'commands.create.locationUser' })}</div>
<div className="text-xs text-muted-foreground">{`~/.${cliType}/commands/`}</div>
</div>
</div>
</button>
</div>
</div>
{/* Mode Selection */}
<div className="space-y-2">
<Label>{formatMessage({ id: 'commands.create.mode' })}</Label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
className={cn(
'px-4 py-3 text-left border-2 rounded-lg transition-all',
mode === 'import'
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
)}
onClick={() => setMode('import')}
>
<div className="flex items-center gap-2">
<FileCode className="w-5 h-5" />
<div>
<div className="font-medium text-sm">{formatMessage({ id: 'commands.create.modeImport' })}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'commands.create.modeImportHint' })}</div>
</div>
</div>
</button>
<button
type="button"
className={cn(
'px-4 py-3 text-left border-2 rounded-lg transition-all',
mode === 'cli-generate'
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50'
)}
onClick={() => setMode('cli-generate')}
>
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5" />
<div>
<div className="font-medium text-sm">{formatMessage({ id: 'commands.create.modeGenerate' })}</div>
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'commands.create.modeGenerateHint' })}</div>
</div>
</div>
</button>
</div>
</div>
{/* Import Mode Content */}
{mode === 'import' && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="sourcePath">{formatMessage({ id: 'commands.create.sourcePath' })}</Label>
<Input
id="sourcePath"
value={sourcePath}
onChange={(e) => {
setSourcePath(e.target.value);
setValidationResult(null);
}}
placeholder={formatMessage({ id: 'commands.create.sourcePathPlaceholder' })}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'commands.create.sourcePathHint' })}</p>
</div>
<div className="space-y-2">
<Label htmlFor="customName">
{formatMessage({ id: 'commands.create.customName' })}
<span className="text-muted-foreground ml-1">({formatMessage({ id: 'commands.create.customNameHint' })})</span>
</Label>
<Input
id="customName"
value={customName}
onChange={(e) => setCustomName(e.target.value)}
placeholder={formatMessage({ id: 'commands.create.customNamePlaceholder' })}
/>
</div>
{/* Validation Result */}
{isValidating && (
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg">
<Loader2 className="w-4 h-4 animate-spin" />
<span className="text-sm text-muted-foreground">{formatMessage({ id: 'commands.create.validating' })}</span>
</div>
)}
{validationResult && !isValidating && (
validationResult.valid ? (
<div className="p-4 bg-green-500/10 border border-green-500/20 rounded-lg">
<div className="flex items-center gap-2 text-green-600 mb-2">
<CheckCircle className="w-5 h-5" />
<span className="font-medium">{formatMessage({ id: 'commands.create.validCommand' })}</span>
</div>
{validationResult.commandInfo && (
<div className="space-y-1 text-sm">
<div>
<span className="text-muted-foreground">{formatMessage({ id: 'commands.card.name' })}: </span>
<span>{validationResult.commandInfo.name}</span>
</div>
{validationResult.commandInfo.description && (
<div>
<span className="text-muted-foreground">{formatMessage({ id: 'commands.card.description' })}: </span>
<span>{validationResult.commandInfo.description}</span>
</div>
)}
{validationResult.commandInfo.usage && (
<div>
<span className="text-muted-foreground">{formatMessage({ id: 'commands.card.usage' })}: </span>
<span>{validationResult.commandInfo.usage}</span>
</div>
)}
</div>
)}
</div>
) : (
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
<div className="flex items-center gap-2 text-destructive mb-2">
<XCircle className="w-5 h-5" />
<span className="font-medium">{formatMessage({ id: 'commands.create.invalidCommand' })}</span>
</div>
{validationResult.errors && (
<ul className="space-y-1 text-sm">
{validationResult.errors.map((error, i) => (
<li key={i} className="text-destructive">{error}</li>
))}
</ul>
)}
</div>
)
)}
</div>
)}
{/* CLI Generate Mode Content */}
{mode === 'cli-generate' && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="commandName">
{formatMessage({ id: 'commands.create.commandName' })} <span className="text-destructive">*</span>
</Label>
<Input
id="commandName"
value={commandName}
onChange={(e) => setCommandName(e.target.value)}
placeholder={formatMessage({ id: 'commands.create.commandNamePlaceholder' })}
/>
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'commands.create.commandNameHint' })}</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">
{formatMessage({ id: 'commands.create.descriptionLabel' })} <span className="text-destructive">*</span>
</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={formatMessage({ id: 'commands.create.descriptionPlaceholder' })}
rows={6}
/>
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'commands.create.descriptionHint' })}</p>
</div>
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<div className="flex items-start gap-2">
<Info className="w-4 h-4 text-blue-600 mt-0.5" />
<div className="text-sm text-blue-600">
<p className="font-medium">{formatMessage({ id: 'commands.create.generateInfo' })}</p>
<p className="text-xs mt-1">{formatMessage({ id: 'commands.create.generateTimeHint' })}</p>
</div>
</div>
</div>
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isCreating}>
{formatMessage({ id: 'commands.actions.cancel' })}
</Button>
{mode === 'import' && (
<Button
variant="outline"
onClick={handleValidate}
disabled={!sourcePath.trim() || isValidating || isCreating}
>
{isValidating && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{formatMessage({ id: 'commands.create.validate' })}
</Button>
)}
<Button
onClick={handleCreate}
disabled={!canCreate}
>
{isCreating && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{isCreating
? formatMessage({ id: 'commands.create.creating' })
: mode === 'import'
? formatMessage({ id: 'commands.create.import' })
: formatMessage({ id: 'commands.create.generate' })
}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export default CommandCreateDialog;

View File

@@ -10,9 +10,13 @@ import {
triggerConsolidation,
getConsolidationStatus,
getV2Jobs,
previewExtractionQueue,
triggerSelectiveExtraction,
type ExtractionStatus,
type ConsolidationStatus,
type V2JobsResponse,
type ExtractionPreviewResponse,
type SelectiveExtractionResponse,
} from '../lib/api';
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
@@ -23,6 +27,8 @@ export const memoryV2Keys = {
consolidationStatus: (path?: string) => [...memoryV2Keys.all, 'consolidation', path] as const,
jobs: (path?: string, filters?: { kind?: string; status_filter?: string }) =>
[...memoryV2Keys.all, 'jobs', path, filters] as const,
preview: (path?: string, includeNative?: boolean) =>
[...memoryV2Keys.all, 'preview', path, includeNative] as const,
};
// Default stale time: 30 seconds (V2 status changes frequently)
@@ -97,5 +103,35 @@ export function useTriggerConsolidation() {
});
}
// Hook: Preview sessions for extraction
export function usePreviewSessions(includeNative: boolean = false) {
const projectPath = useWorkflowStore(selectProjectPath);
return useQuery({
queryKey: memoryV2Keys.preview(projectPath, includeNative),
queryFn: () => previewExtractionQueue(includeNative, undefined, projectPath),
enabled: !!projectPath,
staleTime: 10 * 1000, // 10 seconds
});
}
// Hook: Trigger selective extraction
export function useTriggerSelectiveExtraction() {
const queryClient = useQueryClient();
const projectPath = useWorkflowStore(selectProjectPath);
return useMutation({
mutationFn: (params: { sessionIds: string[]; includeNative?: boolean }) =>
triggerSelectiveExtraction({
sessionIds: params.sessionIds,
includeNative: params.includeNative,
path: projectPath,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: memoryV2Keys.all });
},
});
}
// Export types
export type { ExtractionStatus, ConsolidationStatus, V2JobsResponse };
export type { ExtractionStatus, ConsolidationStatus, V2JobsResponse, ExtractionPreviewResponse, SelectiveExtractionResponse };

View File

@@ -1492,6 +1492,39 @@ export async function getCommandsGroupsConfig(
return fetchApi<{ groups: Record<string, any>; assignments: Record<string, string> }>(`/api/commands/groups/config?${params}`);
}
/**
* Validate a command file for import
*/
export async function validateCommandImport(sourcePath: string): Promise<{
valid: boolean;
errors?: string[];
commandInfo?: { name: string; description: string; version?: string };
}> {
return fetchApi('/api/commands/validate-import', {
method: 'POST',
body: JSON.stringify({ sourcePath }),
});
}
/**
* Create/import a command
*/
export async function createCommand(params: {
mode: 'import' | 'cli-generate';
location: 'project' | 'user';
sourcePath?: string;
commandName?: string;
description?: string;
generationType?: 'description' | 'template';
projectPath?: string;
cliType?: 'claude' | 'codex';
}): Promise<{ commandName: string; path: string }> {
return fetchApi('/api/commands/create', {
method: 'POST',
body: JSON.stringify(params),
});
}
// ========== Memory API ==========
export interface CoreMemory {
@@ -1744,6 +1777,79 @@ export async function getV2Jobs(
return fetchApi<V2JobsResponse>(`/api/core-memory/jobs?${params}`);
}
// ========== Memory V2 Preview API ==========
export interface SessionPreviewItem {
sessionId: string;
source: 'ccw' | 'native';
tool: string;
timestamp: number;
eligible: boolean;
extracted: boolean;
bytes: number;
turns: number;
}
export interface ExtractionPreviewResponse {
success: boolean;
sessions: SessionPreviewItem[];
summary: {
total: number;
eligible: number;
alreadyExtracted: number;
readyForExtraction: number;
};
}
export interface SelectiveExtractionRequest {
sessionIds: string[];
includeNative?: boolean;
path?: string;
}
export interface SelectiveExtractionResponse {
success: boolean;
jobId: string;
queued: number;
skipped: number;
invalidIds: string[];
}
/**
* Preview extraction queue - get list of sessions available for extraction
*/
export async function previewExtractionQueue(
includeNative: boolean = false,
maxSessions?: number,
projectPath?: string
): Promise<ExtractionPreviewResponse> {
const params = new URLSearchParams();
if (projectPath) params.set('path', projectPath);
if (includeNative) params.set('include_native', 'true');
if (maxSessions) params.set('max_sessions', String(maxSessions));
return fetchApi<ExtractionPreviewResponse>(`/api/core-memory/extract/preview?${params}`);
}
/**
* Trigger selective extraction for specific sessions
*/
export async function triggerSelectiveExtraction(
request: SelectiveExtractionRequest
): Promise<SelectiveExtractionResponse> {
const params = new URLSearchParams();
if (request.path) params.set('path', request.path);
return fetchApi<SelectiveExtractionResponse>(
`/api/core-memory/extract/selective?${params}`,
{
method: 'POST',
body: JSON.stringify({
session_ids: request.sessionIds,
include_native: request.includeNative,
}),
}
);
}
// ========== Project Overview API ==========
export interface TechnologyStack {

View File

@@ -74,7 +74,8 @@
"categories": {
"notification": "Notification",
"indexing": "Indexing",
"automation": "Automation"
"automation": "Automation",
"utility": "Utility"
},
"templates": {
"session-start-notify": {
@@ -116,6 +117,30 @@
"project-state-inject": {
"name": "Project State Inject",
"description": "Inject project guidelines and recent dev history at session start"
},
"memory-v2-extract": {
"name": "Memory V2 Extract",
"description": "Trigger Phase 1 extraction when session ends (after idle period)"
},
"memory-v2-auto-consolidate": {
"name": "Memory V2 Auto Consolidate",
"description": "Trigger Phase 2 consolidation after extraction jobs complete"
},
"memory-sync-dashboard": {
"name": "Memory Sync Dashboard",
"description": "Sync memory V2 status to dashboard on changes"
},
"memory-auto-compress": {
"name": "Auto Memory Compress",
"description": "Automatically compress memory when entries exceed threshold"
},
"memory-preview-extract": {
"name": "Memory Preview & Extract",
"description": "Preview extraction queue and extract eligible sessions"
},
"memory-status-check": {
"name": "Memory Status Check",
"description": "Check memory extraction and consolidation status"
}
},
"actions": {

View File

@@ -10,7 +10,8 @@
"collapseAll": "Collapse All",
"copy": "Copy",
"showDisabled": "Show Disabled",
"hideDisabled": "Hide Disabled"
"hideDisabled": "Hide Disabled",
"cancel": "Cancel"
},
"source": {
"builtin": "Built-in",
@@ -57,5 +58,45 @@
"clickToDisableAll": "Click to disable all",
"noCommands": "No commands in this group",
"noEnabledCommands": "No enabled commands in this group"
},
"create": {
"title": "Create Command",
"location": "Location",
"locationProject": "Project Commands",
"locationProjectHint": ".claude/commands/",
"locationUser": "Global Commands",
"locationUserHint": "~/.claude/commands/",
"mode": "Creation Mode",
"modeImport": "Import File",
"modeImportHint": "Import command from existing file",
"modeGenerate": "AI Generate",
"modeGenerateHint": "Generate command using AI",
"sourcePath": "Source File Path",
"sourcePathPlaceholder": "Enter absolute path to command file",
"sourcePathHint": "File must be a valid command markdown file",
"customName": "Custom Name",
"customNamePlaceholder": "Leave empty to use original name",
"customNameHint": "Optional, overrides default command name",
"commandName": "Command Name",
"commandNamePlaceholder": "Enter command name",
"commandNameHint": "Used as the command file name",
"descriptionLabel": "Command Description",
"descriptionPlaceholder": "Describe what this command should do...",
"descriptionHint": "AI will generate command content based on this description",
"generateInfo": "AI will use CLI tools to generate the command",
"generateTimeHint": "Generation may take some time",
"validate": "Validate",
"import": "Import",
"generate": "Generate",
"validating": "Validating...",
"validCommand": "Validation passed",
"invalidCommand": "Validation failed",
"creating": "Creating...",
"created": "Command \"{name}\" created successfully",
"createError": "Failed to create command",
"sourcePathRequired": "Please enter source file path",
"commandNameRequired": "Please enter command name",
"descriptionRequired": "Please enter command description",
"validateFirst": "Please validate the command file first"
}
}

View File

@@ -169,6 +169,10 @@
"saveConfig": "Save Configuration",
"saving": "Saving..."
},
"feedback": {
"saveSuccess": "Configuration saved",
"saveError": "Failed to save configuration"
},
"scope": {
"global": "Global",
"project": "Project",

View File

@@ -180,6 +180,28 @@
"statusBanner": {
"running": "Pipeline Running - {count} job(s) in progress",
"hasErrors": "Pipeline Idle - {count} job(s) failed"
},
"preview": {
"title": "Extraction Queue Preview",
"selectSessions": "Search sessions...",
"sourceCcw": "CCW",
"sourceNative": "Native",
"selectAll": "Select All",
"selectNone": "Select None",
"extractSelected": "Extract Selected ({count})",
"noSessions": "No sessions found",
"total": "Total",
"eligible": "Eligible",
"extracted": "Already Extracted",
"ready": "Ready",
"previewQueue": "Preview Queue",
"includeNative": "Include Native Sessions",
"selected": "{count} sessions selected",
"selectHint": "Select sessions to extract",
"ineligible": "Ineligible"
},
"extraction": {
"selectiveTriggered": "Selective extraction triggered"
}
}
}

View File

@@ -74,7 +74,8 @@
"categories": {
"notification": "通知",
"indexing": "索引",
"automation": "自动化"
"automation": "自动化",
"utility": "实用工具"
},
"templates": {
"session-start-notify": {
@@ -116,6 +117,30 @@
"project-state-inject": {
"name": "项目状态注入",
"description": "会话启动时注入项目约束和最近开发历史"
},
"memory-v2-extract": {
"name": "Memory V2 提取",
"description": "会话结束时触发第一阶段提取(空闲期后)"
},
"memory-v2-auto-consolidate": {
"name": "Memory V2 自动合并",
"description": "提取作业完成后触发第二阶段合并"
},
"memory-sync-dashboard": {
"name": "Memory 同步仪表盘",
"description": "变更时同步 Memory V2 状态到仪表盘"
},
"memory-auto-compress": {
"name": "自动内存压缩",
"description": "当条目超过阈值时自动压缩内存"
},
"memory-preview-extract": {
"name": "内存预览与提取",
"description": "预览提取队列并提取符合条件的会话"
},
"memory-status-check": {
"name": "内存状态检查",
"description": "检查内存提取和合并状态"
}
},
"actions": {

View File

@@ -10,7 +10,8 @@
"collapseAll": "全部收起",
"copy": "复制",
"showDisabled": "显示已禁用",
"hideDisabled": "隐藏已禁用"
"hideDisabled": "隐藏已禁用",
"cancel": "取消"
},
"source": {
"builtin": "内置",
@@ -57,5 +58,45 @@
"clickToDisableAll": "点击全部禁用",
"noCommands": "此分组中没有命令",
"noEnabledCommands": "此分组中没有已启用的命令"
},
"create": {
"title": "创建命令",
"location": "存储位置",
"locationProject": "项目命令",
"locationProjectHint": ".claude/commands/",
"locationUser": "全局命令",
"locationUserHint": "~/.claude/commands/",
"mode": "创建方式",
"modeImport": "导入文件",
"modeImportHint": "从现有文件导入命令",
"modeGenerate": "AI 生成",
"modeGenerateHint": "使用 AI 生成命令",
"sourcePath": "源文件路径",
"sourcePathPlaceholder": "输入命令文件的绝对路径",
"sourcePathHint": "文件必须是有效的命令 Markdown 文件",
"customName": "自定义名称",
"customNamePlaceholder": "留空则使用原始名称",
"customNameHint": "可选,覆盖默认命令名称",
"commandName": "命令名称",
"commandNamePlaceholder": "输入命令名称",
"commandNameHint": "用作命令文件名称",
"descriptionLabel": "命令描述",
"descriptionPlaceholder": "描述这个命令应该做什么...",
"descriptionHint": "AI 将根据描述生成命令内容",
"generateInfo": "AI 将使用 CLI 工具生成命令",
"generateTimeHint": "生成过程可能需要一些时间",
"validate": "验证",
"import": "导入",
"generate": "生成",
"validating": "验证中...",
"validCommand": "验证通过",
"invalidCommand": "验证失败",
"creating": "创建中...",
"created": "命令 \"{name}\" 创建成功",
"createError": "创建命令失败",
"sourcePathRequired": "请输入源文件路径",
"commandNameRequired": "请输入命令名称",
"descriptionRequired": "请输入命令描述",
"validateFirst": "请先验证命令文件"
}
}

View File

@@ -169,6 +169,10 @@
"saveConfig": "保存配置",
"saving": "保存中..."
},
"feedback": {
"saveSuccess": "配置已保存",
"saveError": "保存配置失败"
},
"scope": {
"global": "全局",
"project": "项目",

View File

@@ -180,6 +180,28 @@
"statusBanner": {
"running": "Pipeline 运行中 - {count} 个作业正在执行",
"hasErrors": "Pipeline 空闲 - {count} 个作业失败"
},
"preview": {
"title": "提取队列预览",
"selectSessions": "搜索会话...",
"sourceCcw": "CCW",
"sourceNative": "原生",
"selectAll": "全选",
"selectNone": "取消全选",
"extractSelected": "提取选中 ({count})",
"noSessions": "未找到会话",
"total": "总计",
"eligible": "符合条件",
"extracted": "已提取",
"ready": "就绪",
"previewQueue": "预览队列",
"includeNative": "包含原生会话",
"selected": "已选择 {count} 个会话",
"selectHint": "选择要提取的会话",
"ineligible": "不符合条件"
},
"extraction": {
"selectiveTriggered": "选择性提取已触发"
}
}
}

View File

@@ -252,6 +252,11 @@ export function run(argv: string[]): void {
.option('--batch-size <n>', 'Batch size for embedding', '8')
.option('--top-k <n>', 'Number of semantic search results', '10')
.option('--min-score <f>', 'Minimum similarity score for semantic search', '0.5')
// Pipeline V2 options
.option('--include-native', 'Include native sessions (preview)')
.option('--path <path>', 'Project path (pipeline commands)')
.option('--max-sessions <n>', 'Max sessions to extract (extract)')
.option('--session-ids <ids>', 'Comma-separated session IDs (extract)')
.action((subcommand, args, options) => memoryCommand(subcommand, args, options));
// Core Memory command

View File

@@ -20,6 +20,9 @@ import {
} from '../core/memory-embedder-bridge.js';
import { getCoreMemoryStore } from '../core/core-memory-store.js';
import { CliHistoryStore } from '../tools/cli-history-store.js';
import { MemoryExtractionPipeline, type PreviewResult, type SessionPreviewItem } from '../core/memory-extraction-pipeline.js';
import { MemoryConsolidationPipeline } from '../core/memory-consolidation-pipeline.js';
import { MemoryJobScheduler } from '../core/memory-job-scheduler.js';
interface TrackOptions {
type?: string;
@@ -74,6 +77,28 @@ interface EmbedStatusOptions {
json?: boolean;
}
// Memory Pipeline V2 subcommand options
interface PipelinePreviewOptions {
includeNative?: boolean;
path?: string;
json?: boolean;
}
interface PipelineExtractOptions {
maxSessions?: string;
sessionIds?: string;
path?: string;
}
interface PipelineConsolidateOptions {
path?: string;
}
interface PipelineStatusOptions {
path?: string;
json?: boolean;
}
/**
* Read JSON data from stdin (for Claude Code hooks)
*/
@@ -967,9 +992,388 @@ async function embedStatusAction(options: EmbedStatusOptions): Promise<void> {
}
}
// ============================================================
// Memory Pipeline V2 Subcommands
// ============================================================
/**
* Preview eligible sessions for extraction
*/
async function pipelinePreviewAction(options: PipelinePreviewOptions): Promise<void> {
const { includeNative, path: projectPath, json } = options;
const basePath = projectPath || process.cwd();
try {
const pipeline = new MemoryExtractionPipeline(basePath);
const preview = pipeline.previewEligibleSessions({
includeNative: includeNative || false,
});
if (json) {
console.log(JSON.stringify(preview, null, 2));
return;
}
console.log(chalk.bold.cyan('\n Extraction Queue Preview\n'));
console.log(chalk.gray(` Project: ${basePath}`));
console.log(chalk.gray(` Include Native: ${includeNative ? 'Yes' : 'No'}\n`));
// Summary
const { summary } = preview;
console.log(chalk.bold.white(' Summary:'));
console.log(chalk.white(` Total Sessions: ${summary.total}`));
console.log(chalk.white(` Eligible: ${summary.eligible}`));
console.log(chalk.white(` Already Extracted: ${summary.alreadyExtracted}`));
console.log(chalk.green(` Ready for Extraction: ${summary.readyForExtraction}`));
if (preview.sessions.length === 0) {
console.log(chalk.yellow('\n No eligible sessions found.\n'));
return;
}
// Sessions table
console.log(chalk.bold.white('\n Sessions:\n'));
console.log(chalk.gray(' ID Source Tool Turns Bytes Status'));
console.log(chalk.gray(' ' + '-'.repeat(76)));
for (const session of preview.sessions) {
const id = session.sessionId.padEnd(20);
const source = session.source.padEnd(11);
const tool = (session.tool || '-').padEnd(11);
const turns = String(session.turns).padStart(5);
const bytes = String(session.bytes).padStart(9);
const status = session.extracted
? chalk.green('extracted')
: session.eligible
? chalk.cyan('ready')
: chalk.gray('skipped');
console.log(` ${chalk.dim(id)} ${source} ${tool} ${turns} ${bytes} ${status}`);
}
console.log(chalk.gray('\n ' + '-'.repeat(76)));
console.log(chalk.gray(` Showing ${preview.sessions.length} sessions\n`));
} catch (error) {
if (json) {
console.log(JSON.stringify({ error: (error as Error).message }, null, 2));
} else {
console.error(chalk.red(`\n Error: ${(error as Error).message}\n`));
}
process.exit(1);
}
}
/**
* Trigger extraction for sessions
*/
async function pipelineExtractAction(options: PipelineExtractOptions): Promise<void> {
const { maxSessions, sessionIds, path: projectPath } = options;
const basePath = projectPath || process.cwd();
try {
const store = getCoreMemoryStore(basePath);
const scheduler = new MemoryJobScheduler(store.getDb());
const pipeline = new MemoryExtractionPipeline(basePath);
// Selective extraction with specific session IDs
if (sessionIds) {
const ids = sessionIds.split(',').map(id => id.trim()).filter(Boolean);
if (ids.length === 0) {
console.error(chalk.red('Error: No valid session IDs provided'));
process.exit(1);
}
console.log(chalk.bold.cyan('\n Selective Extraction\n'));
console.log(chalk.gray(` Project: ${basePath}`));
console.log(chalk.gray(` Session IDs: ${ids.join(', ')}\n`));
// Validate sessions
const preview = pipeline.previewEligibleSessions({ includeNative: false });
const validSessionIds = new Set(preview.sessions.map(s => s.sessionId));
const queued: string[] = [];
const skipped: string[] = [];
const invalid: string[] = [];
for (const sessionId of ids) {
if (!validSessionIds.has(sessionId)) {
invalid.push(sessionId);
continue;
}
// Check if already extracted
const existingOutput = store.getStage1Output(sessionId);
if (existingOutput) {
skipped.push(sessionId);
continue;
}
// Enqueue job
scheduler.enqueueJob('phase1_extraction', sessionId, Math.floor(Date.now() / 1000));
queued.push(sessionId);
}
console.log(chalk.green(` Queued: ${queued.length} sessions`));
console.log(chalk.yellow(` Skipped (already extracted): ${skipped.length}`));
if (invalid.length > 0) {
console.log(chalk.red(` Invalid: ${invalid.length}`));
console.log(chalk.gray(` ${invalid.join(', ')}`));
}
// Process queued sessions
if (queued.length > 0) {
console.log(chalk.cyan('\n Processing extraction jobs...\n'));
let succeeded = 0;
let failed = 0;
for (const sessionId of queued) {
try {
await pipeline.runExtractionJob(sessionId);
succeeded++;
console.log(chalk.green(` [OK] ${sessionId}`));
} catch (err) {
failed++;
console.log(chalk.red(` [FAIL] ${sessionId}: ${(err as Error).message}`));
}
}
console.log(chalk.bold.white(`\n Completed: ${succeeded} succeeded, ${failed} failed\n`));
} else {
console.log();
}
return;
}
// Batch extraction
const max = maxSessions ? parseInt(maxSessions, 10) : 10;
console.log(chalk.bold.cyan('\n Batch Extraction\n'));
console.log(chalk.gray(` Project: ${basePath}`));
console.log(chalk.gray(` Max Sessions: ${max}\n`));
// Get eligible sessions
const eligible = pipeline.scanEligibleSessions(max);
const preview = pipeline.previewEligibleSessions({ maxSessions: max });
console.log(chalk.white(` Found ${eligible.length} eligible sessions`));
console.log(chalk.white(` Ready for extraction: ${preview.summary.readyForExtraction}\n`));
if (eligible.length === 0) {
console.log(chalk.yellow(' No eligible sessions to extract.\n'));
return;
}
// Queue jobs
const jobId = `batch-${Date.now()}`;
const queued: string[] = [];
for (const session of eligible) {
const existingOutput = store.getStage1Output(session.id);
if (!existingOutput) {
const watermark = Math.floor(new Date(session.updated_at).getTime() / 1000);
scheduler.enqueueJob('phase1_extraction', session.id, watermark);
queued.push(session.id);
}
}
console.log(chalk.cyan(` Job ID: ${jobId}`));
console.log(chalk.cyan(` Queued: ${queued.length} sessions\n`));
// Process queued sessions
if (queued.length > 0) {
console.log(chalk.cyan(' Processing extraction jobs...\n'));
let succeeded = 0;
let failed = 0;
for (const sessionId of queued) {
try {
await pipeline.runExtractionJob(sessionId);
succeeded++;
console.log(chalk.green(` [OK] ${sessionId}`));
} catch (err) {
failed++;
console.log(chalk.red(` [FAIL] ${sessionId}: ${(err as Error).message}`));
}
}
console.log(chalk.bold.white(`\n Completed: ${succeeded} succeeded, ${failed} failed\n`));
} else {
console.log(chalk.yellow(' No new sessions to extract.\n'));
}
} catch (error) {
console.error(chalk.red(`\n Error: ${(error as Error).message}\n`));
process.exit(1);
}
}
/**
* Trigger consolidation pipeline
*/
async function pipelineConsolidateAction(options: PipelineConsolidateOptions): Promise<void> {
const { path: projectPath } = options;
const basePath = projectPath || process.cwd();
try {
const pipeline = new MemoryConsolidationPipeline(basePath);
console.log(chalk.bold.cyan('\n Memory Consolidation\n'));
console.log(chalk.gray(` Project: ${basePath}\n`));
// Get current status
const status = pipeline.getStatus();
if (status) {
console.log(chalk.white(` Current Status: ${status.status}`));
}
console.log(chalk.cyan('\n Triggering consolidation...\n'));
// Run consolidation
await pipeline.runConsolidation();
console.log(chalk.green(' Consolidation completed successfully.\n'));
// Show result
const memoryMd = pipeline.getMemoryMdContent();
if (memoryMd) {
console.log(chalk.white(' Memory.md Preview:'));
console.log(chalk.gray(' ' + '-'.repeat(60)));
const preview = memoryMd.substring(0, 500);
console.log(chalk.dim(preview.split('\n').map(line => ' ' + line).join('\n')));
if (memoryMd.length > 500) {
console.log(chalk.gray(' ...'));
}
console.log(chalk.gray(' ' + '-'.repeat(60)));
console.log(chalk.gray(` (${memoryMd.length} bytes total)\n`));
}
} catch (error) {
console.error(chalk.red(`\n Error: ${(error as Error).message}\n`));
process.exit(1);
}
}
/**
* Show pipeline status
*/
async function pipelineStatusAction(options: PipelineStatusOptions): Promise<void> {
const { path: projectPath, json } = options;
const basePath = projectPath || process.cwd();
try {
const store = getCoreMemoryStore(basePath);
const scheduler = new MemoryJobScheduler(store.getDb());
// Extraction status
const stage1Count = store.countStage1Outputs();
const extractionJobs = scheduler.listJobs('phase1_extraction');
// Consolidation status
let consolidationStatus = 'unavailable';
let memoryMdAvailable = false;
try {
const consolidationPipeline = new MemoryConsolidationPipeline(basePath);
const status = consolidationPipeline.getStatus();
consolidationStatus = status?.status || 'unknown';
memoryMdAvailable = !!consolidationPipeline.getMemoryMdContent();
} catch {
// Consolidation pipeline may not be initialized
}
// Job counts by status
const jobCounts: Record<string, number> = {};
for (const job of extractionJobs) {
jobCounts[job.status] = (jobCounts[job.status] || 0) + 1;
}
const result = {
extraction: {
stage1Count,
totalJobs: extractionJobs.length,
jobCounts,
recentJobs: extractionJobs.slice(0, 10).map(j => ({
job_key: j.job_key,
status: j.status,
started_at: j.started_at,
finished_at: j.finished_at,
last_error: j.last_error,
})),
},
consolidation: {
status: consolidationStatus,
memoryMdAvailable,
},
};
if (json) {
console.log(JSON.stringify(result, null, 2));
return;
}
console.log(chalk.bold.cyan('\n Memory Pipeline Status\n'));
console.log(chalk.gray(` Project: ${basePath}\n`));
// Extraction status
console.log(chalk.bold.white(' Extraction Pipeline:'));
console.log(chalk.white(` Stage 1 Outputs: ${stage1Count}`));
console.log(chalk.white(` Total Jobs: ${extractionJobs.length}`));
if (Object.keys(jobCounts).length > 0) {
console.log(chalk.white(' Job Status:'));
for (const [status, count] of Object.entries(jobCounts)) {
const statusColor = status === 'completed' ? chalk.green :
status === 'running' ? chalk.yellow : chalk.gray;
console.log(` ${statusColor(status)}: ${count}`);
}
}
// Consolidation status
console.log(chalk.bold.white('\n Consolidation Pipeline:'));
console.log(chalk.white(` Status: ${consolidationStatus}`));
console.log(chalk.white(` Memory.md Available: ${memoryMdAvailable ? 'Yes' : 'No'}`));
// Recent jobs
if (extractionJobs.length > 0) {
console.log(chalk.bold.white('\n Recent Extraction Jobs:\n'));
console.log(chalk.gray(' Status Job Key'));
console.log(chalk.gray(' ' + '-'.repeat(60)));
for (const job of extractionJobs.slice(0, 10)) {
const statusIcon = job.status === 'done' ? chalk.green('done ') :
job.status === 'running' ? chalk.yellow('running ') :
job.status === 'pending' ? chalk.gray('pending ') :
chalk.red('error ');
console.log(` ${statusIcon} ${chalk.dim(job.job_key)}`);
}
if (extractionJobs.length > 10) {
console.log(chalk.gray(` ... and ${extractionJobs.length - 10} more`));
}
}
console.log();
} catch (error) {
if (json) {
console.log(JSON.stringify({ error: (error as Error).message }, null, 2));
} else {
console.error(chalk.red(`\n Error: ${(error as Error).message}\n`));
}
process.exit(1);
}
}
/**
* Memory command entry point
* @param {string} subcommand - Subcommand (track, import, stats, search, suggest, prune, embed, embed-status)
* @param {string} subcommand - Subcommand (track, import, stats, search, suggest, prune, embed, embed-status, preview, extract, consolidate, status)
* @param {string|string[]} args - Arguments array
* @param {Object} options - CLI options
*/
@@ -1018,6 +1422,23 @@ export async function memoryCommand(
await embedStatusAction(options as EmbedStatusOptions);
break;
// Memory Pipeline V2 subcommands
case 'preview':
await pipelinePreviewAction(options as PipelinePreviewOptions);
break;
case 'extract':
await pipelineExtractAction(options as PipelineExtractOptions);
break;
case 'consolidate':
await pipelineConsolidateAction(options as PipelineConsolidateOptions);
break;
case 'status':
await pipelineStatusAction(options as PipelineStatusOptions);
break;
default:
console.log(chalk.bold.cyan('\n CCW Memory Module\n'));
console.log(' Context tracking and prompt optimization.\n');
@@ -1031,6 +1452,12 @@ export async function memoryCommand(
console.log(chalk.gray(' embed Generate embeddings for semantic search'));
console.log(chalk.gray(' embed-status Show embedding generation status'));
console.log();
console.log(chalk.bold.cyan(' Memory Pipeline V2:'));
console.log(chalk.gray(' preview Preview eligible sessions for extraction'));
console.log(chalk.gray(' extract Trigger extraction for sessions'));
console.log(chalk.gray(' consolidate Trigger consolidation pipeline'));
console.log(chalk.gray(' status Show pipeline status'));
console.log();
console.log(' Track Options:');
console.log(chalk.gray(' --type <type> Entity type: file, module, topic'));
console.log(chalk.gray(' --action <action> Action: read, write, mention'));
@@ -1074,6 +1501,25 @@ export async function memoryCommand(
console.log(chalk.gray(' --older-than <age> Age threshold (default: 30d)'));
console.log(chalk.gray(' --dry-run Preview without deleting'));
console.log();
console.log(chalk.bold.cyan(' Pipeline V2 Options:'));
console.log();
console.log(' Preview Options:');
console.log(chalk.gray(' --include-native Include native sessions in preview'));
console.log(chalk.gray(' --path <path> Project path (default: current directory)'));
console.log(chalk.gray(' --json Output as JSON'));
console.log();
console.log(' Extract Options:');
console.log(chalk.gray(' --max-sessions <n> Max sessions to extract (default: 10)'));
console.log(chalk.gray(' --session-ids <ids> Comma-separated session IDs for selective extraction'));
console.log(chalk.gray(' --path <path> Project path (default: current directory)'));
console.log();
console.log(' Consolidate Options:');
console.log(chalk.gray(' --path <path> Project path (default: current directory)'));
console.log();
console.log(' Pipeline Status Options:');
console.log(chalk.gray(' --path <path> Project path (default: current directory)'));
console.log(chalk.gray(' --json Output as JSON'));
console.log();
console.log(' Examples:');
console.log(chalk.gray(' ccw memory track --type file --action read --value "src/auth.ts"'));
console.log(chalk.gray(' ccw memory import --source history --project "my-app"'));
@@ -1086,5 +1532,13 @@ export async function memoryCommand(
console.log(chalk.gray(' ccw memory suggest --context "implementing JWT auth"'));
console.log(chalk.gray(' ccw memory prune --older-than 60d --dry-run'));
console.log();
console.log(chalk.cyan(' Pipeline V2 Examples:'));
console.log(chalk.gray(' ccw memory preview # Preview extraction queue'));
console.log(chalk.gray(' ccw memory preview --include-native # Include native sessions'));
console.log(chalk.gray(' ccw memory extract --max-sessions 10 # Batch extract up to 10'));
console.log(chalk.gray(' ccw memory extract --session-ids sess-1,sess-2 # Selective extraction'));
console.log(chalk.gray(' ccw memory consolidate # Run consolidation'));
console.log(chalk.gray(' ccw memory status # Check pipeline status'));
console.log();
}
}

View File

@@ -3,12 +3,12 @@
* Delegates to team-msg.ts handler for JSONL-based persistent messaging
*
* Commands:
* ccw team log --team <name> --from <role> --to <role> --type <type> --summary "..."
* ccw team read --team <name> --id <MSG-NNN>
* ccw team list --team <name> [--from <role>] [--to <role>] [--type <type>] [--last <n>]
* ccw team status --team <name>
* ccw team delete --team <name> --id <MSG-NNN>
* ccw team clear --team <name>
* ccw team log --team <session-id> --from <role> --to <role> --type <type> --summary "..."
* ccw team read --team <session-id> --id <MSG-NNN>
* ccw team list --team <session-id> [--from <role>] [--to <role>] [--type <type>] [--last <n>]
* ccw team status --team <session-id>
* ccw team delete --team <session-id> --id <MSG-NNN>
* ccw team clear --team <session-id>
*/
import chalk from 'chalk';
@@ -145,7 +145,7 @@ function printHelp(): void {
console.log(chalk.gray(' clear Clear all messages for a team'));
console.log();
console.log(' Required:');
console.log(chalk.gray(' --team <name> Team name'));
console.log(chalk.gray(' --team <session-id> Session ID (e.g., TLS-my-project-2026-02-27), NOT team name'));
console.log();
console.log(' Log Options:');
console.log(chalk.gray(' --from <role> Sender role name'));
@@ -168,12 +168,12 @@ function printHelp(): void {
console.log(chalk.gray(' --json Output as JSON'));
console.log();
console.log(' Examples:');
console.log(chalk.gray(' ccw team log --team my-team --from executor --to coordinator --type impl_complete --summary "Task done"'));
console.log(chalk.gray(' ccw team list --team my-team --last 5'));
console.log(chalk.gray(' ccw team read --team my-team --id MSG-003'));
console.log(chalk.gray(' ccw team status --team my-team'));
console.log(chalk.gray(' ccw team delete --team my-team --id MSG-003'));
console.log(chalk.gray(' ccw team clear --team my-team'));
console.log(chalk.gray(' ccw team log --team my-team --from planner --to coordinator --type plan_ready --summary "Plan ready" --json'));
console.log(chalk.gray(' ccw team log --team TLS-my-project-2026-02-27 --from executor --to coordinator --type impl_complete --summary "Task done"'));
console.log(chalk.gray(' ccw team list --team TLS-my-project-2026-02-27 --last 5'));
console.log(chalk.gray(' ccw team read --team TLS-my-project-2026-02-27 --id MSG-003'));
console.log(chalk.gray(' ccw team status --team TLS-my-project-2026-02-27'));
console.log(chalk.gray(' ccw team delete --team TLS-my-project-2026-02-27 --id MSG-003'));
console.log(chalk.gray(' ccw team clear --team TLS-my-project-2026-02-27'));
console.log(chalk.gray(' ccw team log --team TLS-my-project-2026-02-27 --from planner --to coordinator --type plan_ready --summary "Plan ready" --json'));
console.log();
}

View File

@@ -28,6 +28,8 @@ import {
} from './memory-v2-config.js';
import { EXTRACTION_SYSTEM_PROMPT, buildExtractionUserPrompt } from './memory-extraction-prompts.js';
import { redactSecrets } from '../utils/secret-redactor.js';
import { getNativeSessions, type NativeSession } from '../tools/native-session-discovery.js';
import { existsSync, readFileSync, statSync } from 'fs';
// -- Types --
@@ -58,6 +60,27 @@ export interface BatchExtractionResult {
errors: Array<{ sessionId: string; error: string }>;
}
export interface SessionPreviewItem {
sessionId: string;
source: 'ccw' | 'native';
tool: string;
timestamp: number;
eligible: boolean;
extracted: boolean;
bytes: number;
turns: number;
}
export interface PreviewResult {
sessions: SessionPreviewItem[];
summary: {
total: number;
eligible: number;
alreadyExtracted: number;
readyForExtraction: number;
};
}
// -- Turn type bitmask constants --
/** All turn types included */
@@ -77,6 +100,15 @@ const TRUNCATION_MARKER = '\n\n[... CONTENT TRUNCATED ...]\n\n';
const JOB_KIND_EXTRACTION = 'phase1_extraction';
// -- Authorization error for session access --
export class SessionAccessDeniedError extends Error {
constructor(sessionId: string, projectPath: string) {
super(`Session '${sessionId}' does not belong to project '${projectPath}'`);
this.name = 'SessionAccessDeniedError';
}
}
// -- Pipeline --
export class MemoryExtractionPipeline {
@@ -92,6 +124,58 @@ export class MemoryExtractionPipeline {
this.currentSessionId = options?.currentSessionId;
}
// ========================================================================
// Authorization
// ========================================================================
/**
* Verify that a session belongs to the current project path.
*
* This is a security-critical authorization check to prevent cross-project
* session access. Sessions are scoped to projects, and accessing a session
* from another project should be denied.
*
* @param sessionId - The session ID to verify
* @returns true if the session belongs to this project, false otherwise
*/
verifySessionBelongsToProject(sessionId: string): boolean {
const historyStore = getHistoryStore(this.projectPath);
const session = historyStore.getConversation(sessionId);
// If session exists in this project's history store, it's authorized
if (session) {
return true;
}
// Check native sessions - verify the session file is within project directory
const nativeTools = ['gemini', 'qwen', 'codex', 'claude', 'opencode'] as const;
for (const tool of nativeTools) {
try {
const nativeSessions = getNativeSessions(tool, { workingDir: this.projectPath });
const found = nativeSessions.some(s => s.sessionId === sessionId);
if (found) {
return true;
}
} catch {
// Skip tools with discovery errors
}
}
return false;
}
/**
* Verify session access and throw if unauthorized.
*
* @param sessionId - The session ID to verify
* @throws SessionAccessDeniedError if session doesn't belong to project
*/
private ensureSessionAccess(sessionId: string): void {
if (!this.verifySessionBelongsToProject(sessionId)) {
throw new SessionAccessDeniedError(sessionId, this.projectPath);
}
}
// ========================================================================
// Eligibility scanning
// ========================================================================
@@ -148,6 +232,122 @@ export class MemoryExtractionPipeline {
return eligible;
}
/**
* Preview eligible sessions with detailed information for selective extraction.
*
* Returns session metadata including byte size, turn count, and extraction status.
* Native sessions are returned empty in Phase 1 (Phase 2 will implement native integration).
*
* @param options - Preview options
* @param options.includeNative - Whether to include native sessions (placeholder for Phase 2)
* @param options.maxSessions - Maximum number of sessions to return
* @returns PreviewResult with sessions and summary counts
*/
previewEligibleSessions(options?: { includeNative?: boolean; maxSessions?: number }): PreviewResult {
const store = getCoreMemoryStore(this.projectPath);
const maxSessions = options?.maxSessions || MAX_SESSIONS_PER_STARTUP;
// Scan CCW sessions using existing logic
const ccwSessions = this.scanEligibleSessions(maxSessions);
const sessions: SessionPreviewItem[] = [];
// Process CCW sessions
for (const session of ccwSessions) {
const transcript = this.filterTranscript(session);
const bytes = Buffer.byteLength(transcript, 'utf-8');
const turns = session.turns?.length || 0;
const timestamp = new Date(session.created_at).getTime();
// Check if already extracted
const existingOutput = store.getStage1Output(session.id);
const extracted = existingOutput !== null;
sessions.push({
sessionId: session.id,
source: 'ccw',
tool: session.tool || 'unknown',
timestamp,
eligible: true,
extracted,
bytes,
turns,
});
}
// Native sessions integration (Phase 2)
if (options?.includeNative) {
const nativeTools = ['gemini', 'qwen', 'codex', 'claude', 'opencode'] as const;
const now = Date.now();
const maxAgeMs = MAX_SESSION_AGE_DAYS * 24 * 60 * 60 * 1000;
const minIdleMs = MIN_IDLE_HOURS * 60 * 60 * 1000;
for (const tool of nativeTools) {
try {
const nativeSessions = getNativeSessions(tool, { workingDir: this.projectPath });
for (const session of nativeSessions) {
// Age check: created within MAX_SESSION_AGE_DAYS
if (now - session.createdAt.getTime() > maxAgeMs) continue;
// Idle check: last updated at least MIN_IDLE_HOURS ago
if (now - session.updatedAt.getTime() < minIdleMs) continue;
// Skip current session
if (this.currentSessionId && session.sessionId === this.currentSessionId) continue;
// Get file stats for bytes
let bytes = 0;
let turns = 0;
try {
if (existsSync(session.filePath)) {
const stats = statSync(session.filePath);
bytes = stats.size;
// Parse session file to count turns
turns = this.countNativeSessionTurns(session);
}
} catch {
// Skip sessions with file access errors
continue;
}
// Check if already extracted
const existingOutput = store.getStage1Output(session.sessionId);
const extracted = existingOutput !== null;
sessions.push({
sessionId: session.sessionId,
source: 'native',
tool: session.tool,
timestamp: session.updatedAt.getTime(),
eligible: true,
extracted,
bytes,
turns,
});
}
} catch {
// Skip tools with discovery errors
}
}
}
// Compute summary
const eligible = sessions.filter(s => s.eligible && !s.extracted);
const alreadyExtracted = sessions.filter(s => s.extracted);
return {
sessions,
summary: {
total: sessions.length,
eligible: sessions.filter(s => s.eligible).length,
alreadyExtracted: alreadyExtracted.length,
readyForExtraction: eligible.length,
},
};
}
// ========================================================================
// Transcript filtering
// ========================================================================
@@ -202,6 +402,291 @@ export class MemoryExtractionPipeline {
return parts.join('\n\n');
}
// ========================================================================
// Native session handling
// ========================================================================
/**
* Count the number of turns in a native session file.
*
* Parses the session file based on tool-specific format:
* - Gemini: { messages: [{ type, content }] }
* - Qwen: JSONL with { type, message: { parts: [{ text }] } }
* - Codex: JSONL with session events
* - Claude: JSONL with { type, message } entries
* - OpenCode: Message files in message/<session-id>/ directory
*
* @param session - The native session to count turns for
* @returns Number of turns (user/assistant exchanges)
*/
countNativeSessionTurns(session: NativeSession): number {
try {
const content = readFileSync(session.filePath, 'utf8');
switch (session.tool) {
case 'gemini': {
// Gemini format: JSON with messages array
const data = JSON.parse(content);
if (data.messages && Array.isArray(data.messages)) {
// Count user messages as turns
return data.messages.filter((m: { type: string }) => m.type === 'user').length;
}
return 0;
}
case 'qwen': {
// Qwen format: JSONL
const lines = content.split('\n').filter(l => l.trim());
let turnCount = 0;
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Count user messages
if (entry.type === 'user' || entry.role === 'user') {
turnCount++;
}
} catch {
// Skip invalid lines
}
}
return turnCount;
}
case 'codex': {
// Codex format: JSONL with session events
const lines = content.split('\n').filter(l => l.trim());
let turnCount = 0;
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Count user_message events
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
turnCount++;
}
} catch {
// Skip invalid lines
}
}
return turnCount;
}
case 'claude': {
// Claude format: JSONL
const lines = content.split('\n').filter(l => l.trim());
let turnCount = 0;
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Count user messages (skip meta and command messages)
if (entry.type === 'user' &&
entry.message?.role === 'user' &&
!entry.isMeta) {
turnCount++;
}
} catch {
// Skip invalid lines
}
}
return turnCount;
}
case 'opencode': {
// OpenCode uses separate message files, count from session data
// For now, return a reasonable estimate based on file size
// Actual message counting would require reading message files
const stats = statSync(session.filePath);
// Rough estimate: 1 turn per 2KB of session file
return Math.max(1, Math.floor(stats.size / 2048));
}
default:
return 0;
}
} catch {
return 0;
}
}
/**
* Load and format transcript from a native session file.
*
* Extracts text content from the session file and formats it
* consistently with CCW session transcripts.
*
* @param session - The native session to load
* @returns Formatted transcript string
*/
loadNativeSessionTranscript(session: NativeSession): string {
try {
const content = readFileSync(session.filePath, 'utf8');
const parts: string[] = [];
let turnNum = 1;
switch (session.tool) {
case 'gemini': {
// Gemini format: { messages: [{ type, content }] }
const data = JSON.parse(content);
if (data.messages && Array.isArray(data.messages)) {
for (const msg of data.messages) {
if (msg.type === 'user' && msg.content) {
parts.push(`--- Turn ${turnNum} ---\n[USER] ${msg.content}`);
} else if (msg.type === 'assistant' && msg.content) {
parts.push(`[ASSISTANT] ${msg.content}`);
turnNum++;
}
}
}
break;
}
case 'qwen': {
// Qwen format: JSONL with { type, message: { parts: [{ text }] } }
const lines = content.split('\n').filter(l => l.trim());
for (const line of lines) {
try {
const entry = JSON.parse(line);
// User message
if (entry.type === 'user' && entry.message?.parts) {
const text = entry.message.parts
.filter((p: { text?: string }) => p.text)
.map((p: { text?: string }) => p.text)
.join('\n');
if (text) {
parts.push(`--- Turn ${turnNum} ---\n[USER] ${text}`);
}
}
// Assistant response
else if (entry.type === 'assistant' && entry.message?.parts) {
const text = entry.message.parts
.filter((p: { text?: string }) => p.text)
.map((p: { text?: string }) => p.text)
.join('\n');
if (text) {
parts.push(`[ASSISTANT] ${text}`);
turnNum++;
}
}
// Legacy format
else if (entry.role === 'user' && entry.content) {
parts.push(`--- Turn ${turnNum} ---\n[USER] ${entry.content}`);
} else if (entry.role === 'assistant' && entry.content) {
parts.push(`[ASSISTANT] ${entry.content}`);
turnNum++;
}
} catch {
// Skip invalid lines
}
}
break;
}
case 'codex': {
// Codex format: JSONL with { type, payload }
const lines = content.split('\n').filter(l => l.trim());
for (const line of lines) {
try {
const entry = JSON.parse(line);
// User message
if (entry.type === 'event_msg' &&
entry.payload?.type === 'user_message' &&
entry.payload.message) {
parts.push(`--- Turn ${turnNum} ---\n[USER] ${entry.payload.message}`);
}
// Assistant response
else if (entry.type === 'event_msg' &&
entry.payload?.type === 'assistant_message' &&
entry.payload.message) {
parts.push(`[ASSISTANT] ${entry.payload.message}`);
turnNum++;
}
} catch {
// Skip invalid lines
}
}
break;
}
case 'claude': {
// Claude format: JSONL with { type, message }
const lines = content.split('\n').filter(l => l.trim());
for (const line of lines) {
try {
const entry = JSON.parse(line);
if (entry.type === 'user' && entry.message?.role === 'user' && !entry.isMeta) {
const msgContent = entry.message.content;
// Handle string content
if (typeof msgContent === 'string' &&
!msgContent.startsWith('<command-') &&
!msgContent.includes('<local-command')) {
parts.push(`--- Turn ${turnNum} ---\n[USER] ${msgContent}`);
}
// Handle array content
else if (Array.isArray(msgContent)) {
for (const item of msgContent) {
if (item.type === 'text' && item.text) {
parts.push(`--- Turn ${turnNum} ---\n[USER] ${item.text}`);
break;
}
}
}
}
// Assistant response
else if (entry.type === 'assistant' && entry.message?.content) {
const msgContent = entry.message.content;
if (typeof msgContent === 'string') {
parts.push(`[ASSISTANT] ${msgContent}`);
turnNum++;
} else if (Array.isArray(msgContent)) {
const textParts = msgContent
.filter((item: { type?: string; text?: string }) => item.type === 'text' && item.text)
.map((item: { text?: string }) => item.text)
.join('\n');
if (textParts) {
parts.push(`[ASSISTANT] ${textParts}`);
turnNum++;
}
}
}
} catch {
// Skip invalid lines
}
}
break;
}
case 'opencode': {
// OpenCode stores messages in separate files
// For transcript extraction, read session metadata and messages
// This is a simplified extraction - full implementation would
// traverse message/part directories
try {
const sessionData = JSON.parse(content);
if (sessionData.title) {
parts.push(`--- Session ---\n[SESSION] ${sessionData.title}`);
}
if (sessionData.summary) {
parts.push(`[SUMMARY] ${sessionData.summary}`);
}
} catch {
// Return empty if parsing fails
}
break;
}
default:
break;
}
return parts.join('\n\n');
} catch {
return '';
}
}
// ========================================================================
// Truncation
// ========================================================================
@@ -354,20 +839,55 @@ export class MemoryExtractionPipeline {
/**
* Run the full extraction pipeline for a single session.
*
* Pipeline stages: Filter -> Truncate -> LLM Extract -> PostProcess -> Store
* Pipeline stages: Authorize -> Filter -> Truncate -> LLM Extract -> PostProcess -> Store
*
* SECURITY: This method includes authorization verification to ensure the session
* belongs to the current project path before processing.
*
* @param sessionId - The session to extract from
* @param options - Optional configuration
* @param options.source - 'ccw' for CCW history or 'native' for native CLI sessions
* @param options.nativeSession - Native session data (required when source is 'native')
* @param options.skipAuthorization - Internal use only: skip authorization (already validated)
* @returns The stored Stage1Output, or null if extraction failed
* @throws SessionAccessDeniedError if session doesn't belong to the project
*/
async runExtractionJob(sessionId: string): Promise<Stage1Output | null> {
const historyStore = getHistoryStore(this.projectPath);
const record = historyStore.getConversation(sessionId);
if (!record) {
throw new Error(`Session not found: ${sessionId}`);
async runExtractionJob(
sessionId: string,
options?: {
source?: 'ccw' | 'native';
nativeSession?: NativeSession;
skipAuthorization?: boolean;
}
): Promise<Stage1Output | null> {
// SECURITY: Authorization check - verify session belongs to this project
// Skip only if explicitly requested (for internal batch processing where already validated)
if (!options?.skipAuthorization) {
this.ensureSessionAccess(sessionId);
}
const source = options?.source || 'ccw';
let transcript: string;
let sourceUpdatedAt: number;
if (source === 'native' && options?.nativeSession) {
// Native session extraction
const nativeSession = options.nativeSession;
transcript = this.loadNativeSessionTranscript(nativeSession);
sourceUpdatedAt = Math.floor(nativeSession.updatedAt.getTime() / 1000);
} else {
// CCW session extraction (default)
const historyStore = getHistoryStore(this.projectPath);
const record = historyStore.getConversation(sessionId);
if (!record) {
throw new Error(`Session not found: ${sessionId}`);
}
// Stage 1: Filter transcript
transcript = this.filterTranscript(record);
sourceUpdatedAt = Math.floor(new Date(record.updated_at).getTime() / 1000);
}
// Stage 1: Filter transcript
const transcript = this.filterTranscript(record);
if (!transcript.trim()) {
return null; // Empty transcript, nothing to extract
}
@@ -385,7 +905,6 @@ export class MemoryExtractionPipeline {
const extracted = this.postProcess(llmOutput);
// Stage 5: Store result
const sourceUpdatedAt = Math.floor(new Date(record.updated_at).getTime() / 1000);
const generatedAt = Math.floor(Date.now() / 1000);
const output: Stage1Output = {
@@ -492,7 +1011,8 @@ export class MemoryExtractionPipeline {
const token = claim.ownership_token!;
try {
const output = await this.runExtractionJob(session.id);
// Batch extraction: sessions already validated by scanEligibleSessions(), skip auth check
const output = await this.runExtractionJob(session.id, { skipAuthorization: true });
if (output) {
const watermark = output.source_updated_at;
scheduler.markSucceeded(JOB_KIND_EXTRACTION, session.id, token, watermark);

View File

@@ -7,10 +7,13 @@
* - POST /api/commands/:name/toggle - Enable/disable single command
* - POST /api/commands/group/:groupName/toggle - Batch toggle commands by group
*/
import { existsSync, readdirSync, readFileSync, mkdirSync, renameSync } from 'fs';
import { existsSync, readdirSync, readFileSync, mkdirSync, renameSync, copyFileSync } from 'fs';
import { promises as fsPromises } from 'fs';
import { join, relative, dirname, basename } from 'path';
import { homedir } from 'os';
import { validatePath as validateAllowedPath } from '../../utils/path-validator.js';
import { executeCliTool } from '../../tools/cli-executor.js';
import { SmartContentFormatter } from '../../tools/cli-output-converter.js';
import type { RouteContext } from './types.js';
// ========== Types ==========
@@ -62,6 +65,38 @@ interface CommandGroupsConfig {
assignments: Record<string, string>; // commandName -> groupId mapping
}
/**
* Command creation mode type
*/
type CommandCreationMode = 'upload' | 'generate';
/**
* Parameters for creating a command
*/
interface CreateCommandParams {
mode: CommandCreationMode;
location: CommandLocation;
sourcePath?: string; // Required for 'upload' mode - path to uploaded file
skillName?: string; // Required for 'generate' mode - skill to generate from
description?: string; // Optional description for generated commands
projectPath: string;
cliType?: string; // CLI tool type for generation
}
/**
* Result of command creation operation
*/
interface CommandCreationResult extends CommandOperationResult {
commandInfo?: CommandMetadata | null;
}
/**
* Validation result for command file
*/
type CommandFileValidation =
| { valid: true; errors: string[]; commandInfo: CommandMetadata }
| { valid: false; errors: string[]; commandInfo: null };
// ========== Helper Functions ==========
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -126,6 +161,388 @@ function parseCommandFrontmatter(content: string): CommandMetadata {
return result;
}
/**
* Validate a command file for creation
* Checks file existence, reads content, parses frontmatter, validates required fields
*/
function validateCommandFile(filePath: string): CommandFileValidation {
const errors: string[] = [];
// Check file exists
if (!existsSync(filePath)) {
return { valid: false, errors: ['Command file does not exist'], commandInfo: null };
}
// Check file extension
if (!filePath.endsWith('.md')) {
return { valid: false, errors: ['Command file must be a .md file'], commandInfo: null };
}
// Read file content
let content: string;
try {
content = readFileSync(filePath, 'utf8');
} catch (err) {
return { valid: false, errors: [`Failed to read file: ${(err as Error).message}`], commandInfo: null };
}
// Parse frontmatter
const commandInfo = parseCommandFrontmatter(content);
// Validate required fields
if (!commandInfo.name || commandInfo.name.trim() === '') {
errors.push('Command name is required in frontmatter');
}
// Check for valid frontmatter structure
if (!content.startsWith('---')) {
errors.push('Command file must have YAML frontmatter (starting with ---)');
} else {
const endIndex = content.indexOf('---', 3);
if (endIndex < 0) {
errors.push('Command file has invalid frontmatter (missing closing ---)');
}
}
if (errors.length > 0) {
return { valid: false, errors, commandInfo: null };
}
return { valid: true, errors: [], commandInfo };
}
/**
* Upload (copy) a command file to the commands directory
* Handles group subdirectory creation and path security validation
* @param sourcePath - Source command file path
* @param targetGroup - Target group subdirectory (e.g., 'workflow/review')
* @param location - 'project' or 'user'
* @param projectPath - Project root path
* @param customName - Optional custom filename (without .md extension)
* @returns CommandCreationResult with success status and command info
*/
async function uploadCommand(
sourcePath: string,
targetGroup: string,
location: CommandLocation,
projectPath: string,
customName?: string
): Promise<CommandCreationResult> {
try {
// Validate source file exists and is .md
if (!existsSync(sourcePath)) {
return { success: false, message: 'Source command file does not exist', status: 404 };
}
if (!sourcePath.endsWith('.md')) {
return { success: false, message: 'Source file must be a .md file', status: 400 };
}
// Validate source file content
const validation = validateCommandFile(sourcePath);
if (!validation.valid) {
return { success: false, message: validation.errors.join(', '), status: 400 };
}
// Get target commands directory
const commandsDir = getCommandsDir(location, projectPath);
// Build target path with optional group subdirectory
let targetDir = commandsDir;
if (targetGroup && targetGroup.trim() !== '') {
// Sanitize group path - prevent path traversal
const sanitizedGroup = targetGroup
.replace(/\.\./g, '') // Remove path traversal attempts
.replace(/[<>:"|?*]/g, '') // Remove invalid characters
.replace(/\/+/g, '/') // Collapse multiple slashes
.replace(/^\/|\/$/g, ''); // Remove leading/trailing slashes
if (sanitizedGroup) {
targetDir = join(commandsDir, sanitizedGroup);
}
}
// Create target directory if needed
if (!existsSync(targetDir)) {
mkdirSync(targetDir, { recursive: true });
}
// Determine target filename
const sourceBasename = basename(sourcePath, '.md');
const targetFilename = (customName && customName.trim() !== '')
? `${customName.replace(/\.md$/, '')}.md`
: `${sourceBasename}.md`;
// Sanitize filename - prevent path traversal
const sanitizedFilename = targetFilename
.replace(/\.\./g, '')
.replace(/[<>:"|?*]/g, '')
.replace(/\//g, '');
const targetPath = join(targetDir, sanitizedFilename);
// Security check: ensure target path is within commands directory
const resolvedTarget = targetPath; // Already resolved by join
const resolvedCommandsDir = commandsDir;
if (!resolvedTarget.startsWith(resolvedCommandsDir)) {
return { success: false, message: 'Invalid target path - path traversal detected', status: 400 };
}
// Check if target already exists
if (existsSync(targetPath)) {
return { success: false, message: `Command '${sanitizedFilename}' already exists in target location`, status: 409 };
}
// Copy file to target path
copyFileSync(sourcePath, targetPath);
return {
success: true,
message: 'Command uploaded successfully',
commandName: validation.commandInfo.name,
location,
commandInfo: {
name: validation.commandInfo.name,
description: validation.commandInfo.description,
group: targetGroup || 'other'
}
};
} catch (error) {
return {
success: false,
message: (error as Error).message,
status: 500
};
}
}
/**
* Generation parameters for command generation via CLI
*/
interface CommandGenerationParams {
commandName: string;
description: string;
location: CommandLocation;
projectPath: string;
group?: string;
argumentHint?: string;
broadcastToClients?: (data: unknown) => void;
cliType?: string;
}
/**
* Generate command via CLI tool using command-generator skill
* Follows the pattern from skills-routes.ts generateSkillViaCLI
* @param params - Generation parameters including name, description, location, etc.
* @returns CommandCreationResult with success status and generated command info
*/
async function generateCommandViaCLI({
commandName,
description,
location,
projectPath,
group,
argumentHint,
broadcastToClients,
cliType = 'claude'
}: CommandGenerationParams): Promise<CommandCreationResult> {
// Generate unique execution ID for tracking
const executionId = `cmd-gen-${commandName}-${Date.now()}`;
try {
// Validate required inputs
if (!commandName || commandName.trim() === '') {
return { success: false, message: 'Command name is required', status: 400 };
}
if (!description || description.trim() === '') {
return { success: false, message: 'Description is required for command generation', status: 400 };
}
// Sanitize command name - prevent path traversal
if (commandName.includes('..') || commandName.includes('/') || commandName.includes('\\')) {
return { success: false, message: 'Invalid command name - path characters not allowed', status: 400 };
}
// Get target commands directory
const commandsDir = getCommandsDir(location, projectPath);
// Build target path with optional group subdirectory
let targetDir = commandsDir;
if (group && group.trim() !== '') {
const sanitizedGroup = group
.replace(/\.\./g, '')
.replace(/[<>:"|?*]/g, '')
.replace(/\/+/g, '/')
.replace(/^\/|\/$/g, '');
if (sanitizedGroup) {
targetDir = join(commandsDir, sanitizedGroup);
}
}
const targetPath = join(targetDir, `${commandName}.md`);
// Check if command already exists
if (existsSync(targetPath)) {
return {
success: false,
message: `Command '${commandName}' already exists in ${location} location${group ? ` (group: ${group})` : ''}`,
status: 409
};
}
// Ensure target directory exists
if (!existsSync(targetDir)) {
await fsPromises.mkdir(targetDir, { recursive: true });
}
// Build target location display for prompt
const targetLocationDisplay = location === 'project'
? '.claude/commands/'
: '~/.claude/commands/';
// Build structured command parameters for /command-generator skill
const commandParams = {
skillName: commandName,
description,
location,
group: group || '',
argumentHint: argumentHint || ''
};
// Prompt that invokes /command-generator skill with structured parameters
const prompt = `/command-generator
## Command Parameters (Structured Input)
\`\`\`json
${JSON.stringify(commandParams, null, 2)}
\`\`\`
## User Request
Create a new Claude Code command with the following specifications:
- **Command Name**: ${commandName}
- **Description**: ${description}
- **Target Location**: ${targetLocationDisplay}${group ? `${group}/` : ''}${commandName}.md
- **Location Type**: ${location === 'project' ? 'Project-level (.claude/commands/)' : 'User-level (~/.claude/commands/)'}
${group ? `- **Group**: ${group}` : ''}
${argumentHint ? `- **Argument Hint**: ${argumentHint}` : ''}
## Instructions
1. Use the command-generator skill to create a command file with proper YAML frontmatter
2. Include name, description in frontmatter${group ? '\n3. Include group in frontmatter' : ''}${argumentHint ? '\n4. Include argument-hint in frontmatter' : ''}
3. Generate useful command content and usage examples
4. Output the file to: ${targetPath}`;
// Broadcast CLI_EXECUTION_STARTED event
if (broadcastToClients) {
broadcastToClients({
type: 'CLI_EXECUTION_STARTED',
payload: {
executionId,
tool: cliType,
mode: 'write',
category: 'internal',
context: 'command-generation',
commandName
}
});
}
// Create onOutput callback for real-time streaming
const onOutput = broadcastToClients
? (unit: import('../../tools/cli-output-converter.js').CliOutputUnit) => {
const content = SmartContentFormatter.format(unit.content, unit.type);
broadcastToClients({
type: 'CLI_OUTPUT',
payload: {
executionId,
chunkType: unit.type,
data: content
}
});
}
: undefined;
// Execute CLI tool with write mode
const startTime = Date.now();
const result = await executeCliTool({
tool: cliType,
prompt,
mode: 'write',
cd: projectPath,
timeout: 600000, // 10 minutes
category: 'internal',
id: executionId
}, onOutput);
// Broadcast CLI_EXECUTION_COMPLETED event
if (broadcastToClients) {
broadcastToClients({
type: 'CLI_EXECUTION_COMPLETED',
payload: {
executionId,
success: result.success,
status: result.execution?.status || (result.success ? 'success' : 'error'),
duration_ms: Date.now() - startTime
}
});
}
// Check if execution was successful
if (!result.success) {
return {
success: false,
message: `CLI generation failed: ${result.stderr || 'Unknown error'}`,
status: 500
};
}
// Validate the generated command file exists
if (!existsSync(targetPath)) {
return {
success: false,
message: 'Generated command file not found at expected location',
status: 500
};
}
// Validate the generated command file content
const validation = validateCommandFile(targetPath);
if (!validation.valid) {
return {
success: false,
message: `Generated command is invalid: ${validation.errors.join(', ')}`,
status: 500
};
}
return {
success: true,
message: 'Command generated successfully',
commandName: validation.commandInfo.name,
location,
commandInfo: {
name: validation.commandInfo.name,
description: validation.commandInfo.description,
group: validation.commandInfo.group
}
};
} catch (error) {
return {
success: false,
message: (error as Error).message,
status: 500
};
}
}
/**
* Get command groups config file path
*/
@@ -616,5 +1033,103 @@ export async function handleCommandsRoutes(ctx: RouteContext): Promise<boolean>
return true;
}
// POST /api/commands/create - Create command (upload or generate)
if (pathname === '/api/commands/create' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
if (!isRecord(body)) {
return { success: false, message: 'Invalid request body', status: 400 };
}
const mode = body.mode;
const locationValue = body.location;
const sourcePath = typeof body.sourcePath === 'string' ? body.sourcePath : undefined;
const skillName = typeof body.skillName === 'string' ? body.skillName : undefined;
const description = typeof body.description === 'string' ? body.description : undefined;
const group = typeof body.group === 'string' ? body.group : undefined;
const argumentHint = typeof body.argumentHint === 'string' ? body.argumentHint : undefined;
const projectPathParam = typeof body.projectPath === 'string' ? body.projectPath : undefined;
const cliType = typeof body.cliType === 'string' ? body.cliType : 'claude';
// Validate mode
if (mode !== 'upload' && mode !== 'generate') {
return { success: false, message: 'Mode is required and must be "upload" or "generate"', status: 400 };
}
// Validate location
if (locationValue !== 'project' && locationValue !== 'user') {
return { success: false, message: 'Location is required (project or user)', status: 400 };
}
const location: CommandLocation = locationValue;
const projectPath = projectPathParam || initialPath;
// Validate project path for security
let validatedProjectPath = projectPath;
if (location === 'project') {
try {
validatedProjectPath = await validateAllowedPath(projectPath, { mustExist: true, allowedDirectories: [initialPath] });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = message.includes('Access denied') ? 403 : 400;
console.error(`[Commands] Project path validation failed: ${message}`);
return { success: false, message: status === 403 ? 'Access denied' : 'Invalid path', status };
}
}
if (mode === 'upload') {
// Upload mode: copy existing command file
if (!sourcePath) {
return { success: false, message: 'Source path is required for upload mode', status: 400 };
}
// Validate source path for security
let validatedSourcePath: string;
try {
validatedSourcePath = await validateAllowedPath(sourcePath, { mustExist: true });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const status = message.includes('Access denied') ? 403 : 400;
console.error(`[Commands] Source path validation failed: ${message}`);
return { success: false, message: status === 403 ? 'Access denied' : 'Invalid source path', status };
}
return await uploadCommand(
validatedSourcePath,
group || '',
location,
validatedProjectPath
);
} else if (mode === 'generate') {
// Generate mode: use CLI to generate command
if (!skillName) {
return { success: false, message: 'Skill name is required for generate mode', status: 400 };
}
if (!description) {
return { success: false, message: 'Description is required for generate mode', status: 400 };
}
// Validate skill name for security
if (skillName.includes('..') || skillName.includes('/') || skillName.includes('\\')) {
return { success: false, message: 'Invalid skill name - path characters not allowed', status: 400 };
}
return await generateCommandViaCLI({
commandName: skillName,
description,
location,
projectPath: validatedProjectPath,
group,
argumentHint,
broadcastToClients: ctx.broadcastToClients,
cliType
});
}
// This should never be reached due to mode validation above
return { success: false, message: 'Invalid mode', status: 400 };
});
return true;
}
return false;
}

View File

@@ -10,6 +10,54 @@ import { StoragePaths } from '../../config/storage-paths.js';
import { join } from 'path';
import { getDefaultTool } from '../../tools/claude-cli-tools.js';
// ========================================
// Error Handling Utilities
// ========================================
/**
* Sanitize error message for client response
* Logs full error server-side, returns user-friendly message to client
*/
function sanitizeErrorMessage(error: unknown, context: string): string {
const errorMessage = error instanceof Error ? error.message : String(error);
// Log full error for debugging (server-side only)
if (process.env.DEBUG || process.env.NODE_ENV === 'development') {
console.error(`[CoreMemoryRoutes] ${context}:`, error);
}
// Map common internal errors to user-friendly messages
const lowerMessage = errorMessage.toLowerCase();
if (lowerMessage.includes('enoent') || lowerMessage.includes('no such file')) {
return 'Resource not found';
}
if (lowerMessage.includes('eacces') || lowerMessage.includes('permission denied')) {
return 'Access denied';
}
if (lowerMessage.includes('sqlite') || lowerMessage.includes('database')) {
return 'Database operation failed';
}
if (lowerMessage.includes('json') || lowerMessage.includes('parse')) {
return 'Invalid data format';
}
// Return generic message for unexpected errors (don't expose internals)
return 'An unexpected error occurred';
}
/**
* Write error response with sanitized message
*/
function writeErrorResponse(
res: http.ServerResponse,
statusCode: number,
message: string
): void {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: message }));
}
/**
* Route context interface
*/
@@ -303,6 +351,190 @@ export async function handleCoreMemoryRoutes(ctx: RouteContext): Promise<boolean
return true;
}
// API: Preview eligible sessions for selective extraction
if (pathname === '/api/core-memory/extract/preview' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;
const includeNative = url.searchParams.get('includeNative') === 'true';
const maxSessionsParam = url.searchParams.get('maxSessions');
const maxSessions = maxSessionsParam ? parseInt(maxSessionsParam, 10) : undefined;
// Validate maxSessions parameter
if (maxSessionsParam && (isNaN(maxSessions as number) || (maxSessions as number) < 1)) {
writeErrorResponse(res, 400, 'Invalid maxSessions parameter: must be a positive integer');
return true;
}
try {
const { MemoryExtractionPipeline } = await import('../memory-extraction-pipeline.js');
const pipeline = new MemoryExtractionPipeline(projectPath);
const preview = pipeline.previewEligibleSessions({
includeNative,
maxSessions,
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
sessions: preview.sessions,
summary: preview.summary,
}));
} catch (error: unknown) {
// Log full error server-side, return sanitized message to client
writeErrorResponse(res, 500, sanitizeErrorMessage(error, 'extract/preview'));
}
return true;
}
// API: Selective extraction for specific sessions
if (pathname === '/api/core-memory/extract/selected' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { sessionIds, includeNative, path: projectPath } = body;
const basePath = projectPath || initialPath;
// Validate sessionIds - return 400 for invalid input
if (!Array.isArray(sessionIds)) {
return { error: 'sessionIds must be an array', status: 400 };
}
if (sessionIds.length === 0) {
return { error: 'sessionIds cannot be empty', status: 400 };
}
// Validate each sessionId is a non-empty string
for (const id of sessionIds) {
if (typeof id !== 'string' || id.trim() === '') {
return { error: 'Each sessionId must be a non-empty string', status: 400 };
}
}
try {
const store = getCoreMemoryStore(basePath);
const scheduler = new MemoryJobScheduler(store.getDb());
const { MemoryExtractionPipeline, SessionAccessDeniedError } = await import('../memory-extraction-pipeline.js');
const pipeline = new MemoryExtractionPipeline(basePath);
// Get preview to validate sessions (project-scoped)
const preview = pipeline.previewEligibleSessions({ includeNative });
const validSessionIds = new Set(preview.sessions.map(s => s.sessionId));
// Return 404 if no eligible sessions exist at all
if (validSessionIds.size === 0) {
return { error: 'No eligible sessions found for extraction', status: 404 };
}
const queued: string[] = [];
const skipped: string[] = [];
const invalidIds: string[] = [];
const unauthorizedIds: string[] = [];
for (const sessionId of sessionIds) {
// SECURITY: Verify session belongs to this project
// This double-checks that the sessionId is from the project-scoped preview
if (!validSessionIds.has(sessionId)) {
// Check if it's unauthorized (exists but not in this project)
if (!pipeline.verifySessionBelongsToProject(sessionId)) {
unauthorizedIds.push(sessionId);
} else {
invalidIds.push(sessionId);
}
continue;
}
// Check if already extracted
const existingOutput = store.getStage1Output(sessionId);
if (existingOutput) {
skipped.push(sessionId);
continue;
}
// Get session info for watermark
const historyStore = (await import('../../tools/cli-history-store.js')).getHistoryStore(basePath);
const session = historyStore.getConversation(sessionId);
if (!session) {
invalidIds.push(sessionId);
continue;
}
// Enqueue job
const watermark = Math.floor(new Date(session.updated_at).getTime() / 1000);
scheduler.enqueueJob('phase1_extraction', sessionId, watermark);
queued.push(sessionId);
}
// Return 409 Conflict if all sessions were already extracted
if (queued.length === 0 && skipped.length === sessionIds.length) {
return {
error: 'All specified sessions have already been extracted',
status: 409,
skipped
};
}
// Return 404 if no valid sessions were found (all were invalid or unauthorized)
if (queued.length === 0 && skipped.length === 0) {
return { error: 'No valid sessions found among the provided IDs', status: 404 };
}
// Generate batch job ID
const jobId = `batch-${Date.now()}`;
// Broadcast start event
broadcastToClients({
type: 'MEMORY_EXTRACTION_STARTED',
payload: {
timestamp: new Date().toISOString(),
jobId,
queuedCount: queued.length,
selective: true,
}
});
// Fire-and-forget: process queued sessions
// Sessions already validated above, skip auth check for efficiency
(async () => {
try {
for (const sessionId of queued) {
try {
await pipeline.runExtractionJob(sessionId, { skipAuthorization: true });
} catch (err) {
if (process.env.DEBUG) {
console.warn(`[SelectiveExtraction] Failed for ${sessionId}:`, (err as Error).message);
}
}
}
broadcastToClients({
type: 'MEMORY_EXTRACTION_COMPLETED',
payload: { timestamp: new Date().toISOString(), jobId }
});
} catch (err) {
broadcastToClients({
type: 'MEMORY_EXTRACTION_FAILED',
payload: {
timestamp: new Date().toISOString(),
jobId,
error: (err as Error).message,
}
});
}
})();
// Include unauthorizedIds in response for security transparency
return {
success: true,
jobId,
queued: queued.length,
skipped: skipped.length,
invalidIds,
...(unauthorizedIds.length > 0 && { unauthorizedIds }),
};
} catch (error: unknown) {
// Log full error server-side, return sanitized message to client
return { error: sanitizeErrorMessage(error, 'extract/selected'), status: 500 };
}
});
return true;
}
// API: Get extraction pipeline status
if (pathname === '/api/core-memory/extract/status' && req.method === 'GET') {
const projectPath = url.searchParams.get('path') || initialPath;

View File

@@ -7,6 +7,7 @@ import Database from 'better-sqlite3';
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, rmdirSync } from 'fs';
import { join, dirname, resolve } from 'path';
import { parseSessionFile, formatConversation, extractConversationPairs, type ParsedSession, type ParsedTurn } from './session-content-parser.js';
import { getDiscoverer, getNativeSessions } from './native-session-discovery.js';
import { StoragePaths, ensureStorageDir, getProjectId, getCCWHome } from '../config/storage-paths.js';
import type { CliOutputUnit } from './cli-output-converter.js';
@@ -1065,11 +1066,94 @@ export class CliHistoryStore {
*/
async getNativeSessionContent(ccwId: string): Promise<ParsedSession | null> {
const mapping = this.getNativeSessionMapping(ccwId);
if (!mapping || !mapping.native_session_path) {
return null;
if (mapping?.native_session_path) {
const parsed = await parseSessionFile(mapping.native_session_path, mapping.tool);
if (parsed) {
return parsed;
}
// If mapping exists but file is missing/invalid, fall through to re-discovery.
}
return parseSessionFile(mapping.native_session_path, mapping.tool);
// On-demand discovery/backfill: attempt to locate native session file from conversation metadata.
try {
const conversation = this.getConversation(ccwId);
if (!conversation) return null;
const tool = conversation.tool;
const discoverer = getDiscoverer(tool);
if (!discoverer) return null;
const createdMs = Date.parse(conversation.created_at);
const updatedMs = Date.parse(conversation.updated_at || conversation.created_at);
const durationMs = conversation.total_duration_ms || 0;
const endMs = Number.isFinite(updatedMs)
? updatedMs
: (Number.isFinite(createdMs) ? createdMs + durationMs : NaN);
if (!Number.isFinite(endMs)) return null;
const afterTimestamp = Number.isFinite(createdMs) ? new Date(createdMs - 60_000) : undefined;
const sessions = getNativeSessions(tool, { workingDir: this.projectPath, afterTimestamp });
if (sessions.length === 0) return null;
// Prefer sessions whose updatedAt is close to execution end time.
const timeWindowMs = Math.max(5 * 60_000, durationMs + 2 * 60_000);
const timeCandidates = sessions.filter(s => Math.abs(s.updatedAt.getTime() - endMs) <= timeWindowMs);
const candidates = timeCandidates.length > 0
? timeCandidates
: sessions
.map(session => ({ session, timeDiffMs: Math.abs(session.updatedAt.getTime() - endMs) }))
.sort((a, b) => a.timeDiffMs - b.timeDiffMs)
.slice(0, 50)
.map(x => x.session);
const prompt = conversation.turns[0]?.prompt || '';
const promptPrefix = prompt.substring(0, 200).trim();
const scored = candidates
.map(session => {
let promptMatch = false;
if (promptPrefix) {
try {
const firstUserMessage = discoverer.extractFirstUserMessage(session.filePath);
promptMatch = !!firstUserMessage && firstUserMessage.includes(promptPrefix);
} catch {
// Ignore extraction errors (still allow time-based match)
}
}
return {
session,
promptMatch,
timeDiffMs: Math.abs(session.updatedAt.getTime() - endMs)
};
})
.sort((a, b) => {
if (a.promptMatch !== b.promptMatch) return a.promptMatch ? -1 : 1;
return a.timeDiffMs - b.timeDiffMs;
});
const best = scored[0]?.session;
if (!best) return null;
// Persist mapping for future loads (best-effort).
try {
this.saveNativeSessionMapping({
ccw_id: ccwId,
tool,
native_session_id: best.sessionId,
native_session_path: best.filePath,
project_hash: best.projectHash,
created_at: new Date().toISOString()
});
} catch {
// Ignore persistence errors; still attempt to return content.
}
return await parseSessionFile(best.filePath, tool);
} catch {
return null;
}
}
/**

View File

@@ -4,7 +4,7 @@
*/
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
import { join, basename, resolve } from 'path';
import { join, basename, dirname, resolve } from 'path';
// basename is used for extracting session ID from filename
import { createHash } from 'crypto';
import { homedir } from 'os';
@@ -43,6 +43,48 @@ function getHomePath(): string {
return homedir().replace(/\\/g, '/');
}
/**
* Normalize a project root path for comparing against Gemini's `projects.json` keys
* and `.project_root` marker file contents.
*
* On Windows Gemini uses lowercased absolute paths with backslashes.
*/
function normalizeGeminiProjectRootPath(projectDir: string): string {
const absolutePath = resolve(projectDir);
if (process.platform !== 'win32') return absolutePath;
return absolutePath.replace(/\//g, '\\').toLowerCase();
}
let geminiProjectsCache:
| { configPath: string; mtimeMs: number; projects: Record<string, string> }
| null = null;
/**
* Load Gemini project mapping from `~/.gemini/projects.json` (best-effort).
* Format: { "projects": { "<projectRoot>": "<projectName>" } }
*/
function getGeminiProjectsMap(): Record<string, string> | null {
const configPath = join(getHomePath(), '.gemini', 'projects.json');
try {
const stat = statSync(configPath);
if (geminiProjectsCache?.configPath === configPath && geminiProjectsCache.mtimeMs === stat.mtimeMs) {
return geminiProjectsCache.projects;
}
const raw = readFileSync(configPath, 'utf8');
const parsed = JSON.parse(raw) as { projects?: Record<string, string> };
if (!parsed.projects || typeof parsed.projects !== 'object') {
return null;
}
geminiProjectsCache = { configPath, mtimeMs: stat.mtimeMs, projects: parsed.projects };
return parsed.projects;
} catch {
return null;
}
}
/**
* Base session discoverer interface
*/
@@ -177,12 +219,76 @@ abstract class SessionDiscoverer {
/**
* Gemini Session Discoverer
* Path: ~/.gemini/tmp/<projectHash>/chats/session-*.json
* Legacy path: ~/.gemini/tmp/<projectHash>/chats/session-*.json
* Current path (Gemini CLI): ~/.gemini/tmp/<projectName>/chats/session-*.json
*/
class GeminiSessionDiscoverer extends SessionDiscoverer {
tool = 'gemini';
basePath = join(getHomePath(), '.gemini', 'tmp');
private getProjectFoldersForWorkingDir(workingDir: string): string[] {
const folders = new Set<string>();
// Legacy: hashed folder
const projectHash = calculateProjectHash(workingDir);
if (existsSync(join(this.basePath, projectHash))) {
folders.add(projectHash);
}
// Current: project-name folder resolved via ~/.gemini/projects.json
let hasProjectNameFolder = false;
const projectsMap = getGeminiProjectsMap();
if (projectsMap) {
const normalized = normalizeGeminiProjectRootPath(workingDir);
// Prefer exact match first, then walk up parents (Gemini can map nested roots)
let cursor: string | null = normalized;
while (cursor) {
const mapped = projectsMap[cursor];
if (mapped) {
const mappedPath = join(this.basePath, mapped);
if (existsSync(mappedPath)) {
folders.add(mapped);
hasProjectNameFolder = true;
}
break;
}
const parent = dirname(cursor);
cursor = parent !== cursor ? parent : null;
}
}
// Fallback: scan for `.project_root` marker (best-effort; avoids missing mappings)
if (!hasProjectNameFolder) {
const normalized = normalizeGeminiProjectRootPath(workingDir);
try {
if (existsSync(this.basePath)) {
for (const dirName of readdirSync(this.basePath)) {
const fullPath = join(this.basePath, dirName);
try {
if (!statSync(fullPath).isDirectory()) continue;
const markerPath = join(fullPath, '.project_root');
if (!existsSync(markerPath)) continue;
const marker = readFileSync(markerPath, 'utf8').trim();
if (normalizeGeminiProjectRootPath(marker) === normalized) {
folders.add(dirName);
break;
}
} catch {
// Ignore invalid entries
}
}
}
} catch {
// Ignore scan failures
}
}
return Array.from(folders);
}
getSessions(options: SessionDiscoveryOptions = {}): NativeSession[] {
const { workingDir, limit, afterTimestamp } = options;
const sessions: NativeSession[] = [];
@@ -193,9 +299,7 @@ class GeminiSessionDiscoverer extends SessionDiscoverer {
// If workingDir provided, only look in that project's folder
let projectDirs: string[];
if (workingDir) {
const projectHash = calculateProjectHash(workingDir);
const projectPath = join(this.basePath, projectHash);
projectDirs = existsSync(projectPath) ? [projectHash] : [];
projectDirs = this.getProjectFoldersForWorkingDir(workingDir);
} else {
projectDirs = readdirSync(this.basePath).filter(d => {
const fullPath = join(this.basePath, d);
@@ -203,8 +307,8 @@ class GeminiSessionDiscoverer extends SessionDiscoverer {
});
}
for (const projectHash of projectDirs) {
const chatsDir = join(this.basePath, projectHash, 'chats');
for (const projectFolder of projectDirs) {
const chatsDir = join(this.basePath, projectFolder, 'chats');
if (!existsSync(chatsDir)) continue;
const sessionFiles = readdirSync(chatsDir)
@@ -217,7 +321,10 @@ class GeminiSessionDiscoverer extends SessionDiscoverer {
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
for (const file of sessionFiles) {
if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue;
if (afterTimestamp && file.stat.mtime <= afterTimestamp) {
// sessionFiles are sorted descending by mtime, we can stop early
break;
}
try {
const content = JSON.parse(readFileSync(file.path, 'utf8'));
@@ -225,7 +332,7 @@ class GeminiSessionDiscoverer extends SessionDiscoverer {
sessionId: content.sessionId,
tool: this.tool,
filePath: file.path,
projectHash,
projectHash: content.projectHash,
createdAt: new Date(content.startTime || file.stat.birthtime),
updatedAt: new Date(content.lastUpdated || file.stat.mtime)
});
@@ -238,7 +345,14 @@ class GeminiSessionDiscoverer extends SessionDiscoverer {
// Sort by updatedAt descending
sessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
return limit ? sessions.slice(0, limit) : sessions;
const seen = new Set<string>();
const uniqueSessions = sessions.filter(s => {
if (seen.has(s.sessionId)) return false;
seen.add(s.sessionId);
return true;
});
return limit ? uniqueSessions.slice(0, limit) : uniqueSessions;
} catch {
return [];
}

View File

@@ -162,7 +162,7 @@ Message types: plan_ready, plan_approved, plan_revision, task_unblocked, impl_co
},
team: {
type: 'string',
description: 'Team name',
description: 'Session ID (e.g., TLS-my-project-2026-02-27). Maps to .workflow/.team/{session-id}/.msg/. Use session ID, NOT team name.',
},
from: { type: 'string', description: '[log/list] Sender role' },
to: { type: 'string', description: '[log/list] Recipient role' },

View File

@@ -18,9 +18,10 @@ import { after, afterEach, before, beforeEach, describe, it, mock } from 'node:t
import assert from 'node:assert/strict';
import { existsSync, mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { join, resolve } from 'node:path';
const TEST_CCW_HOME = mkdtempSync(join(tmpdir(), 'ccw-session-discovery-home-'));
const TEST_USER_HOME = mkdtempSync(join(tmpdir(), 'ccw-session-discovery-user-home-'));
const TEST_PROJECT_ROOT = mkdtempSync(join(tmpdir(), 'ccw-session-discovery-project-'));
const sessionDiscoveryUrl = new URL('../dist/tools/native-session-discovery.js', import.meta.url);
@@ -34,7 +35,17 @@ let mod: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let cliExecutorMod: any;
const originalEnv = { CCW_DATA_DIR: process.env.CCW_DATA_DIR };
const originalEnv = {
CCW_DATA_DIR: process.env.CCW_DATA_DIR,
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE
};
function normalizeGeminiProjectRootPath(projectDir: string): string {
const absolutePath = resolve(projectDir);
if (process.platform !== 'win32') return absolutePath;
return absolutePath.replace(/\//g, '\\').toLowerCase();
}
function resetDir(dirPath: string): void {
if (existsSync(dirPath)) {
@@ -49,11 +60,13 @@ function resetDir(dirPath: string): void {
function createMockGeminiSession(filePath: string, options: {
sessionId: string;
startTime: string;
projectHash?: string;
transactionId?: string;
firstPrompt?: string;
}): void {
const sessionData = {
sessionId: options.sessionId,
projectHash: options.projectHash,
startTime: options.startTime,
lastUpdated: new Date().toISOString(),
messages: [
@@ -121,6 +134,8 @@ function createMockCodexSession(filePath: string, options: {
describe('Native Session Discovery - Resume Mechanism Fixes (L0-L2)', async () => {
before(async () => {
process.env.CCW_DATA_DIR = TEST_CCW_HOME;
process.env.HOME = TEST_USER_HOME;
process.env.USERPROFILE = TEST_USER_HOME;
mod = await import(sessionDiscoveryUrl.href);
cliExecutorMod = await import(cliExecutorUrl.href);
});
@@ -132,6 +147,7 @@ describe('Native Session Discovery - Resume Mechanism Fixes (L0-L2)', async () =
mock.method(console, 'log', () => {});
resetDir(TEST_CCW_HOME);
resetDir(TEST_USER_HOME);
});
afterEach(() => {
@@ -140,7 +156,10 @@ describe('Native Session Discovery - Resume Mechanism Fixes (L0-L2)', async () =
after(() => {
process.env.CCW_DATA_DIR = originalEnv.CCW_DATA_DIR;
process.env.HOME = originalEnv.HOME;
process.env.USERPROFILE = originalEnv.USERPROFILE;
rmSync(TEST_CCW_HOME, { recursive: true, force: true });
rmSync(TEST_USER_HOME, { recursive: true, force: true });
rmSync(TEST_PROJECT_ROOT, { recursive: true, force: true });
});
@@ -361,6 +380,39 @@ describe('Native Session Discovery - Resume Mechanism Fixes (L0-L2)', async () =
});
});
describe('L1: Gemini discovery - project-name folder layout', () => {
it('discovers sessions under ~/.gemini/tmp/<projectName>/chats via projects.json mapping', () => {
const projectName = `proj-${Date.now()}`;
const projectRootKey = normalizeGeminiProjectRootPath(TEST_PROJECT_ROOT);
const geminiHome = join(TEST_USER_HOME, '.gemini');
const tmpDir = join(geminiHome, 'tmp');
const projectDir = join(tmpDir, projectName);
const chatsDir = join(projectDir, 'chats');
mkdirSync(chatsDir, { recursive: true });
// Gemini uses both projects.json mapping and a `.project_root` marker.
mkdirSync(geminiHome, { recursive: true });
writeFileSync(
join(geminiHome, 'projects.json'),
JSON.stringify({ projects: { [projectRootKey]: projectName } }),
'utf8'
);
writeFileSync(join(projectDir, '.project_root'), projectRootKey, 'utf8');
const sessionPath = join(chatsDir, `session-test-${Date.now()}.json`);
createMockGeminiSession(sessionPath, {
sessionId: `uuid-${Date.now()}`,
startTime: new Date().toISOString(),
projectHash: 'abc123',
firstPrompt: 'Test Gemini prompt'
});
const sessions = mod.getNativeSessions('gemini', { workingDir: TEST_PROJECT_ROOT });
assert.ok(sessions.some((s: { filePath: string }) => s.filePath === sessionPath));
});
});
describe('L1: Prompt-based fallback matching', () => {
it('matches sessions by prompt prefix when transaction ID not available', () => {
const prompt = 'Implement authentication feature with JWT tokens';