diff --git a/src/data.js b/src/data.js index 9f1bc76..4ea3b59 100644 --- a/src/data.js +++ b/src/data.js @@ -165,11 +165,14 @@ const CURSOR_GLOBAL_DB = path.join(CURSOR_APP_DATA, 'User', 'globalStorage', 'st const CURSOR_WORKSPACE_STORAGE = path.join(CURSOR_APP_DATA, 'User', 'workspaceStorage'); const HISTORY_FILE = path.join(CLAUDE_DIR, 'history.jsonl'); const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects'); +const DROID_DIR = path.join(ALL_HOMES[0], '.factory'); +const DROID_SESSIONS_DIR = path.join(DROID_DIR, 'sessions'); // On WSL, collect all alternative data dirs const EXTRA_CLAUDE_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.claude')).filter(d => fs.existsSync(d)); const EXTRA_CODEX_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.codex')).filter(d => fs.existsSync(d)); const EXTRA_CURSOR_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.cursor')).filter(d => fs.existsSync(d)); +const EXTRA_DROID_DIRS = ALL_HOMES.slice(1).map(h => path.join(h, '.factory')).filter(d => fs.existsSync(d)); // Extra OpenCode/Kiro DBs on Windows side const EXTRA_OPENCODE_DBS = ALL_HOMES.slice(1).map(h => path.join(h, 'AppData', 'Local', 'opencode', 'opencode.db')).filter(d => fs.existsSync(d)); @@ -181,6 +184,7 @@ if (IS_WSL) { if (EXTRA_CLAUDE_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Windows-side Claude dirs:', EXTRA_CLAUDE_DIRS.join(', ')); if (EXTRA_CODEX_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Windows-side Codex dirs:', EXTRA_CODEX_DIRS.join(', ')); if (EXTRA_CURSOR_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Windows-side Cursor dirs:', EXTRA_CURSOR_DIRS.join(', ')); + if (EXTRA_DROID_DIRS.length) console.log(' \x1b[36m[WSL]\x1b[0m Windows-side Droid dirs:', EXTRA_DROID_DIRS.join(', ')); } // ── Helpers ──────────────────────────────────────────────── @@ -1448,6 +1452,132 @@ function scanCodexSessions() { return sessions; } +// ── Factory Droid ────────────────────────────────────────── + +function parseDroidSessionFile(sessionFile) { + if (!fs.existsSync(sessionFile)) return null; + + let stat; + let lines; + try { + stat = fs.statSync(sessionFile); + lines = readLines(sessionFile); + } catch { + return null; + } + + let projectPath = ''; + let sessionTitle = ''; + let msgCount = 0; + let userMsgCount = 0; + let firstMsg = ''; + let firstTs = stat.mtimeMs; + let lastTs = stat.mtimeMs; + const mcpSet = new Set(); + + for (const line of lines) { + try { + const entry = JSON.parse(line); + + // session_start → extract cwd and title + if (entry.type === 'session_start') { + if (entry.cwd && !projectPath) projectPath = entry.cwd; + if (entry.sessionTitle) sessionTitle = entry.sessionTitle; + continue; + } + + // message → same format as Claude Code: {type: "message", message: {role, content: [blocks]}} + if (entry.type === 'message') { + const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN; + if (Number.isFinite(ts)) { + if (ts < firstTs) firstTs = ts; + if (ts > lastTs) lastTs = ts; + } + + const msg = entry.message || {}; + const role = msg.role; + if (role !== 'user' && role !== 'assistant') continue; + + const content = extractContent(msg.content); + if (!content || isSystemMessage(content)) continue; + + // MCP tool_use detection from assistant content blocks + if (role === 'assistant' && Array.isArray(msg.content)) { + for (const block of msg.content) { + if (block.type === 'tool_use') { + const name = block.name || ''; + if (name.startsWith('mcp__')) { + const parts = name.split('__'); + if (parts.length >= 3) mcpSet.add(parts[1]); + } + } + } + } + + msgCount++; + if (role === 'user') userMsgCount++; + if (!firstMsg) firstMsg = content.slice(0, 200); + } + } catch {} + } + + return { + projectPath, + sessionTitle, + msgCount, + userMsgCount, + firstMsg, + firstTs, + lastTs, + fileSize: stat.size, + mcpServers: Array.from(mcpSet), + }; +} + +function scanDroidSessions() { + const sessions = []; + if (!fs.existsSync(DROID_SESSIONS_DIR)) return sessions; + + try { + // Structure: ~/.factory/sessions//.jsonl + for (const projDir of fs.readdirSync(DROID_SESSIONS_DIR)) { + const projPath = path.join(DROID_SESSIONS_DIR, projDir); + try { + if (!fs.statSync(projPath).isDirectory()) continue; + } catch { continue; } + + for (const file of fs.readdirSync(projPath)) { + if (!file.endsWith('.jsonl')) continue; + const sid = file.replace('.jsonl', ''); + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(sid)) continue; + + const fp = path.join(projPath, file); + const summary = parseDroidSessionFile(fp); + if (!summary) continue; + + sessions.push({ + id: sid, + tool: 'droid', + project: summary.projectPath, + project_short: summary.projectPath ? summary.projectPath.replace(os.homedir(), '~') : '', + first_ts: summary.firstTs, + last_ts: summary.lastTs, + messages: summary.msgCount, + first_message: summary.sessionTitle || summary.firstMsg || '', + has_detail: true, + file_size: summary.fileSize, + detail_messages: summary.msgCount, + user_messages: summary.userMsgCount || 0, + mcp_servers: summary.mcpServers || [], + skills: [], + }); + } + } + } catch {} + + return sessions; +} + // ── Git root resolver ─────────────────────────────────────── // // Priority order for determining the git root of a session: @@ -1550,6 +1680,7 @@ const SESSIONS_CACHE_TTL = 60000; // 60 seconds — hot cache, invalidated by fi let _historyMtime = 0; let _historySize = 0; let _projectsDirMtime = 0; +let _droidDirMtime = 0; function _sessionsNeedRescan() { // Check if history.jsonl or projects dir changed since last scan @@ -1562,6 +1693,10 @@ function _sessionsNeedRescan() { const st = fs.statSync(PROJECTS_DIR); if (st.mtimeMs !== _projectsDirMtime) return true; } + if (fs.existsSync(DROID_SESSIONS_DIR)) { + const st = fs.statSync(DROID_SESSIONS_DIR); + if (st.mtimeMs !== _droidDirMtime) return true; + } } catch {} return false; } @@ -1576,6 +1711,9 @@ function _updateScanMarkers() { if (fs.existsSync(PROJECTS_DIR)) { _projectsDirMtime = fs.statSync(PROJECTS_DIR).mtimeMs; } + if (fs.existsSync(DROID_SESSIONS_DIR)) { + _droidDirMtime = fs.statSync(DROID_SESSIONS_DIR).mtimeMs; + } } catch {} } @@ -1801,6 +1939,16 @@ function loadSessions() { } } catch {} + // Load Droid sessions + if (fs.existsSync(DROID_DIR)) { + try { + const droidSessions = scanDroidSessions(); + for (const ds of droidSessions) { + sessions[ds.id] = ds; + } + } catch {} + } + // WSL: also load from Windows-side dirs for (const extraClaudeDir of EXTRA_CLAUDE_DIRS) { try { @@ -2037,6 +2185,22 @@ function loadSessionDetail(sessionId, project) { messages.push(msg); } } + } else if (found.format === 'droid') { + // Droid format: same as Claude — {type: "message", message: {role, content: [blocks]}} + if (entry.type !== 'message') continue; + const msg = entry.message || {}; + const role = msg.role; + if (role === 'user' || role === 'assistant') { + const content = extractContent(msg.content); + if (content && !isSystemMessage(content)) { + const m = { role, content: content.slice(0, 2000), uuid: entry.id || '' }; + if (role === 'assistant' && Array.isArray(msg.content)) { + const tools = extractTools(msg.content); + if (tools.length > 0) m.tools = tools; + } + messages.push(m); + } + } } else { // Codex format: response_item with payload if (entry.type === 'response_item' && entry.payload) { @@ -2186,12 +2350,7 @@ function exportSessionMarkdown(sessionId, project) { // For non-Claude formats, use the detail loader for markdown export if (found && found.format !== 'claude') { - const detail = - found.format === 'cursor' ? loadCursorDetail(sessionId) : - found.format === 'opencode' ? loadOpenCodeDetail(sessionId) : - found.format === 'kiro' ? loadKiroDetail(sessionId) : - found.format === 'kilo' ? loadKiloCliDetail(sessionId) : - null; + const detail = loadSessionDetail(sessionId, project); if (detail && detail.messages && detail.messages.length > 0) { const parts = [`# Session ${sessionId}\n\n**Project:** ${project || '(none)'}\n`]; for (const msg of detail.messages) { @@ -2303,6 +2462,25 @@ function _buildSessionFileIndex() { } catch {} } + // Index Droid project files + if (fs.existsSync(DROID_SESSIONS_DIR)) { + try { + const walkDir = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walkDir(full); + else if (entry.name.endsWith('.jsonl')) { + const uuidMatch = entry.name.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/); + if (uuidMatch && !_sessionFileIndex[uuidMatch[1]]) { + _sessionFileIndex[uuidMatch[1]] = { file: full, format: 'droid' }; + } + } + } + }; + walkDir(DROID_SESSIONS_DIR); + } catch {} + } + _sessionFileIndexTs = now; } @@ -2396,6 +2574,24 @@ function findSessionFile(sessionId, project) { } catch {} } + // Try Droid projects dir (walk recursively) + if (fs.existsSync(DROID_SESSIONS_DIR)) { + const walkDir = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + const result = walkDir(full); + if (result) return result; + } else if (entry.name.includes(sessionId) && entry.name.endsWith('.jsonl')) { + return full; + } + } + return null; + }; + const droidFile = walkDir(DROID_SESSIONS_DIR); + if (droidFile) return { file: droidFile, format: 'droid' }; + } + return null; } @@ -2411,6 +2607,9 @@ function isSystemMessage(text) { // Codex developer role system prompts if (t.startsWith('You are Codex')) return true; if (t.startsWith('Filesystem sandboxing')) return true; + // Droid system prompts + if (t.startsWith('You are Droid')) return true; + if (t.startsWith('/dev/null | grep -E "claude|codex|opencode|kiro-cli|cursor-agent|kilo" | grep -v grep || true', + 'ps aux 2>/dev/null | grep -E "claude|codex|opencode|kiro-cli|cursor-agent|kilo|droid" | grep -v grep || true', { encoding: 'utf8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] } ); @@ -3497,6 +3729,14 @@ function _computeSessionDailyBreakdown(s, found) { const c = (entry.message || {}).content; if (Array.isArray(c)) { for (const p of c) { if (p.type === 'text' && p.text && p.text.replace(/<\/?user_query>/g,'').trim()) { hasText = true; break; } } } else if (typeof c === 'string' && c.trim()) hasText = true; + } else if (found.format === 'droid') { + if (entry.type !== 'message') continue; + const msg = entry.message || {}; + if (msg.role !== 'user') continue; + isUser = true; + ts = entry.timestamp ? Date.parse(entry.timestamp) : s.first_ts; + const c = extractContent(msg.content); + if (c && c.trim()) hasText = true; } else if (found.format === 'codex') { if (entry.type === 'response_item' && entry.payload && entry.payload.role === 'user') { isUser = true; @@ -3711,6 +3951,7 @@ module.exports = { KILO_DB, HISTORY_FILE, PROJECTS_DIR, + DROID_DIR, __test: { parseWslDistroList, getWslDistroList, diff --git a/src/frontend/analytics.js b/src/frontend/analytics.js index ed71d09..a3d9c1b 100644 --- a/src/frontend/analytics.js +++ b/src/frontend/analytics.js @@ -72,6 +72,10 @@ async function renderAnalytics(container) { coverageparts.push(byAgent['opencode'].estimated ? 'OpenCode ~est.' : 'OpenCode \u2713'); + if (byAgent['droid'] && byAgent['droid'].sessions > 0) + coverageparts.push(byAgent['droid'].estimated + ? 'Factory Droid ~est.' + : 'Factory Droid \u2713'); ['cursor', 'kiro'].forEach(function(a) { if (noCost[a] > 0) coverageparts.push('' + a + ' \u2717 (no token data)'); @@ -222,7 +226,7 @@ async function renderAnalytics(container) { agentEntries.forEach(function(entry) { var name = entry[0]; var info = entry[1]; var pct = maxAgentCost > 0 ? (info.cost / maxAgentCost * 100) : 0; - var label = { 'claude': 'Claude Code', 'claude-ext': 'Claude Ext', 'codex': 'Codex', 'opencode': 'OpenCode', 'cursor': 'Cursor', 'kiro': 'Kiro', 'kilo': 'Kilo CLI' }[name] || name; + var label = { 'claude': 'Claude Code', 'claude-ext': 'Claude Ext', 'codex': 'Codex', 'opencode': 'OpenCode', 'cursor': 'Cursor', 'kiro': 'Kiro', 'kilo': 'Kilo CLI', 'droid': 'Factory Droid' }[name] || name; var estMark = info.estimated ? ' ~est.' : ''; html += '
'; html += '' + label + estMark + ''; diff --git a/src/frontend/app.js b/src/frontend/app.js index 38bd8bf..018a39b 100644 --- a/src/frontend/app.js +++ b/src/frontend/app.js @@ -1792,6 +1792,12 @@ var AGENT_INSTALL = { alt: null, url: 'https://kilo.ai', }, + droid: { + name: 'Factory Droid', + cmd: 'curl -fsSL https://app.factory.ai/cli | sh', + alt: 'npm i -g droid', + url: 'https://factory.ai', + }, }; function installAgent(agent) { diff --git a/src/frontend/calendar.js b/src/frontend/calendar.js index 648f994..1e1979c 100644 --- a/src/frontend/calendar.js +++ b/src/frontend/calendar.js @@ -196,6 +196,9 @@ function setView(view) { } else if (view === 'kilo-only') { toolFilter = toolFilter === 'kilo' ? null : 'kilo'; currentView = 'sessions'; + } else if (view === 'droid-only') { + toolFilter = toolFilter === 'droid' ? null : 'droid'; + currentView = 'sessions'; } else { toolFilter = null; currentView = view; diff --git a/src/frontend/detail.js b/src/frontend/detail.js index 8555320..1f6b384 100644 --- a/src/frontend/detail.js +++ b/src/frontend/detail.js @@ -279,7 +279,9 @@ function launchSession(sessionId, tool, project, flags) { function copyResume(sessionId, tool) { var s = allSessions.find(function(x) { return x.id === sessionId; }); var cmd; - if (tool === 'codex') { + if (tool === 'droid') { + cmd = 'droid --resume ' + sessionId; + } else if (tool === 'codex') { cmd = 'codex resume ' + sessionId; } else if (tool === 'kilo') { cmd = 'kilo resume ' + sessionId; diff --git a/src/frontend/heatmap.js b/src/frontend/heatmap.js index 94a35ae..049bf93 100644 --- a/src/frontend/heatmap.js +++ b/src/frontend/heatmap.js @@ -179,7 +179,7 @@ function renderHeatmap(container) { // Per-tool breakdown var toolTotals = {}; allSessions.forEach(function(s) { if (s.date >= yearStart) { toolTotals[s.tool] = (toolTotals[s.tool] || 0) + 1; } }); - var toolColors = { claude: '#60a5fa', codex: '#22d3ee', opencode: '#c084fc', kiro: '#fb923c', kilo: '#34d399' }; + var toolColors = { claude: '#60a5fa', codex: '#22d3ee', opencode: '#c084fc', kiro: '#fb923c', kilo: '#34d399', droid: '#4ade80' }; html += '
'; Object.keys(toolTotals).sort(function(a,b) { return toolTotals[b] - toolTotals[a]; }).forEach(function(tool) { var pct = (toolTotals[tool] / Math.max(totalThisYear, 1) * 100).toFixed(0); diff --git a/src/frontend/index.html b/src/frontend/index.html index 215cad7..63e6e19 100644 --- a/src/frontend/index.html +++ b/src/frontend/index.html @@ -80,6 +80,10 @@ Kilo
+ +