dr-claw 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1380 -0
- package/NOTICE +17 -0
- package/README.md +875 -0
- package/README.zh-CN.md +857 -0
- package/dist/api-docs.html +878 -0
- package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/_basePickBy-C0n1uo4u.js +1 -0
- package/dist/assets/_baseUniq-BRNmhFkc.js +1 -0
- package/dist/assets/arc-DOMBmC_A.js +1 -0
- package/dist/assets/architectureDiagram-VXUJARFQ-CtMWglbD.js +36 -0
- package/dist/assets/blockDiagram-VD42YOAC-DK_gTkJy.js +122 -0
- package/dist/assets/c4Diagram-YG6GDRKO-DrDAvsn7.js +10 -0
- package/dist/assets/channel-BFOE7OyC.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-CGbhdZcN.js +1 -0
- package/dist/assets/chunk-55IACEB6-sGeFSxen.js +1 -0
- package/dist/assets/chunk-B4BG7PRW-RxgHAU_P.js +165 -0
- package/dist/assets/chunk-DI55MBZ5-CvHUXZrx.js +220 -0
- package/dist/assets/chunk-FMBD7UC4-Sn7cXVNU.js +15 -0
- package/dist/assets/chunk-QN33PNHL-bDKOHppN.js +1 -0
- package/dist/assets/chunk-QZHKN3VN-B9kN5LGm.js +1 -0
- package/dist/assets/chunk-TZMSLE5B-CCBe6len.js +1 -0
- package/dist/assets/classDiagram-2ON5EDUG-DQoMNYlY.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-DQoMNYlY.js +1 -0
- package/dist/assets/clone-BulQSblH.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-r6sLiHBW.js +1 -0
- package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
- package/dist/assets/dagre-6UL2VRFP-AbsaR1Oa.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-PSM6KHXK-DjCvdUyy.js +24 -0
- package/dist/assets/diagram-QEK2KX5R-CqiocXkH.js +43 -0
- package/dist/assets/diagram-S2PKOQOG-D5ALeoII.js +24 -0
- package/dist/assets/erDiagram-Q2GNP2WA-C1AeOsiz.js +60 -0
- package/dist/assets/flowDiagram-NV44I4VS-C98ve_DO.js +162 -0
- package/dist/assets/ganttDiagram-JELNMOA3-3zdLvcxD.js +267 -0
- package/dist/assets/gitGraphDiagram-V2S2FVAM-nDuDMsHh.js +65 -0
- package/dist/assets/graph-Blm-emkR.js +1 -0
- package/dist/assets/index-B8Yutsnm.css +1 -0
- package/dist/assets/index-Cf6gyhc3.js +654 -0
- package/dist/assets/infoDiagram-HS3SLOUP-CmzE3q0R.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/journeyDiagram-XKPGCS4Q-CYaW7LrP.js +139 -0
- package/dist/assets/kanban-definition-3W4ZIXB7-mkmW7p0j.js +89 -0
- package/dist/assets/layout-Ct2kFIId.js +1 -0
- package/dist/assets/linear-CKG9EwIJ.js +1 -0
- package/dist/assets/mermaid.core-CaKcUnnd.js +250 -0
- package/dist/assets/mindmap-definition-VGOIOE7T-Bct-pYin.js +68 -0
- package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/assets/pieDiagram-ADFJNKIX-C8wFD4Jr.js +30 -0
- package/dist/assets/quadrantDiagram-AYHSOK5B-CuiE_f46.js +7 -0
- package/dist/assets/requirementDiagram-UZGBJVZJ-C4v1CqU8.js +64 -0
- package/dist/assets/sankeyDiagram-TZEHDZUN-keAqqs-Y.js +10 -0
- package/dist/assets/sequenceDiagram-WL72ISMW-ClNbvKTB.js +145 -0
- package/dist/assets/stateDiagram-FKZM4ZOC-D8We-2vq.js +1 -0
- package/dist/assets/stateDiagram-v2-4FDKWEC3-BUURfjrE.js +1 -0
- package/dist/assets/timeline-definition-IT6M3QCI-DsvEMT5T.js +61 -0
- package/dist/assets/treemap-GDKQZRPO-TJrzN7Bj.js +162 -0
- package/dist/assets/vendor-codemirror-ansM-eig.js +33 -0
- package/dist/assets/vendor-react-BWF6YQQ3.js +8 -0
- package/dist/assets/vendor-xterm-DfcmCpbH.js +66 -0
- package/dist/assets/xychartDiagram-PRI3JC2R-BdATmY5F.js +7 -0
- package/dist/clear-cache.html +85 -0
- package/dist/convert-icons.md +53 -0
- package/dist/dr-claw.png +0 -0
- package/dist/favicon.png +0 -0
- package/dist/generate-icons.js +49 -0
- package/dist/icons/claude-ai-icon.svg +1 -0
- package/dist/icons/codex-white.svg +3 -0
- package/dist/icons/codex.svg +3 -0
- package/dist/icons/cursor-white.svg +12 -0
- package/dist/icons/cursor.svg +1 -0
- package/dist/icons/file.svg +16997 -0
- package/dist/icons/generate-icons.md +19 -0
- package/dist/icons/icon-128x128.png +0 -0
- package/dist/icons/icon-144x144.png +0 -0
- package/dist/icons/icon-152x152.png +0 -0
- package/dist/icons/icon-192x192.png +0 -0
- package/dist/icons/icon-384x384.png +0 -0
- package/dist/icons/icon-512x512.png +0 -0
- package/dist/icons/icon-72x72.png +0 -0
- package/dist/icons/icon-96x96.png +0 -0
- package/dist/icons/news/arxiv.svg +1 -0
- package/dist/icons/news/huggingface.svg +8 -0
- package/dist/icons/news/x-black.png +0 -0
- package/dist/icons/news/x-white.png +0 -0
- package/dist/icons/news/xiaohongshu.png +0 -0
- package/dist/index.html +51 -0
- package/dist/leveraged-cognition.png +0 -0
- package/dist/manifest.json +61 -0
- package/dist/screenshots/chat.png +0 -0
- package/dist/screenshots/new_claw.png +0 -0
- package/dist/screenshots/news_dashboard.png +0 -0
- package/dist/screenshots/project_dashboard.png +0 -0
- package/dist/screenshots/research_lab.png +0 -0
- package/dist/screenshots/skill_library.png +0 -0
- package/dist/screenshots/task_list.png +0 -0
- package/dist/screenshots/terminal_example.png +0 -0
- package/dist/screenshots/terminal_example1.png +0 -0
- package/dist/sw.js +58 -0
- package/dist/wechat-group-qr.jpg +0 -0
- package/package.json +133 -0
- package/public/api-docs.html +878 -0
- package/public/clear-cache.html +85 -0
- package/public/convert-icons.md +53 -0
- package/public/dr-claw.png +0 -0
- package/public/favicon.png +0 -0
- package/public/generate-icons.js +49 -0
- package/public/icons/claude-ai-icon.svg +1 -0
- package/public/icons/codex-white.svg +3 -0
- package/public/icons/codex.svg +3 -0
- package/public/icons/cursor-white.svg +12 -0
- package/public/icons/cursor.svg +1 -0
- package/public/icons/file.svg +16997 -0
- package/public/icons/generate-icons.md +19 -0
- package/public/icons/icon-128x128.png +0 -0
- package/public/icons/icon-144x144.png +0 -0
- package/public/icons/icon-152x152.png +0 -0
- package/public/icons/icon-192x192.png +0 -0
- package/public/icons/icon-384x384.png +0 -0
- package/public/icons/icon-512x512.png +0 -0
- package/public/icons/icon-72x72.png +0 -0
- package/public/icons/icon-96x96.png +0 -0
- package/public/icons/news/arxiv.svg +1 -0
- package/public/icons/news/huggingface.svg +8 -0
- package/public/icons/news/x-black.png +0 -0
- package/public/icons/news/x-white.png +0 -0
- package/public/icons/news/xiaohongshu.png +0 -0
- package/public/leveraged-cognition.png +0 -0
- package/public/manifest.json +61 -0
- package/public/screenshots/chat.png +0 -0
- package/public/screenshots/new_claw.png +0 -0
- package/public/screenshots/news_dashboard.png +0 -0
- package/public/screenshots/project_dashboard.png +0 -0
- package/public/screenshots/research_lab.png +0 -0
- package/public/screenshots/skill_library.png +0 -0
- package/public/screenshots/task_list.png +0 -0
- package/public/screenshots/terminal_example.png +0 -0
- package/public/screenshots/terminal_example1.png +0 -0
- package/public/sw.js +58 -0
- package/public/wechat-group-qr.jpg +0 -0
- package/scripts/export-skills-catalog-v2.mjs +746 -0
- package/scripts/fix-node-pty.js +45 -0
- package/scripts/gemini_image_core.py +470 -0
- package/scripts/install-python-tools.js +64 -0
- package/server/AGENTS.md +73 -0
- package/server/__tests__/gemini-legacy-thought.test.mjs +88 -0
- package/server/__tests__/runtimePorts.test.mjs +115 -0
- package/server/__tests__/session-delete.test.mjs +166 -0
- package/server/bin/compute-node +70 -0
- package/server/claude-sdk.js +799 -0
- package/server/cli-chat.js +488 -0
- package/server/cli.js +354 -0
- package/server/compute-node.js +439 -0
- package/server/constants/config.js +5 -0
- package/server/cursor-cli.js +340 -0
- package/server/database/auth.db +0 -0
- package/server/database/db.js +1852 -0
- package/server/database/init.sql +194 -0
- package/server/gemini-cli.js +1352 -0
- package/server/index.js +3154 -0
- package/server/load-env.js +71 -0
- package/server/local-gpu.js +860 -0
- package/server/middleware/auth.js +100 -0
- package/server/openai-codex.js +724 -0
- package/server/openrouter.js +890 -0
- package/server/project-token-usage.js +407 -0
- package/server/projects.js +3973 -0
- package/server/routes/agent.js +1272 -0
- package/server/routes/auth.js +128 -0
- package/server/routes/auto-research.js +558 -0
- package/server/routes/cli-auth.js +958 -0
- package/server/routes/codex.js +344 -0
- package/server/routes/commands.js +601 -0
- package/server/routes/compute.js +358 -0
- package/server/routes/cursor.js +795 -0
- package/server/routes/git.js +1348 -0
- package/server/routes/mcp-utils.js +48 -0
- package/server/routes/mcp.js +618 -0
- package/server/routes/news.js +711 -0
- package/server/routes/projects.js +633 -0
- package/server/routes/references.js +338 -0
- package/server/routes/settings.js +285 -0
- package/server/routes/skills.js +914 -0
- package/server/routes/taskmaster.js +2566 -0
- package/server/routes/telemetry.js +89 -0
- package/server/routes/user.js +155 -0
- package/server/scripts/fix_session_names.js +57 -0
- package/server/scripts/research-news/common_words.py +19 -0
- package/server/scripts/research-news/link_keywords.py +298 -0
- package/server/scripts/research-news/scan_existing_notes.py +266 -0
- package/server/scripts/research-news/scoring_utils.py +227 -0
- package/server/scripts/research-news/search_arxiv.py +842 -0
- package/server/scripts/research-news/search_huggingface.py +438 -0
- package/server/scripts/research-news/search_x.py +490 -0
- package/server/scripts/research-news/search_xiaohongshu.py +610 -0
- package/server/taskmaster-templates/README.md +150 -0
- package/server/taskmaster-templates/README.zh-CN.md +150 -0
- package/server/taskmaster-templates/ai-research-dataset.json +230 -0
- package/server/taskmaster-templates/ai-research-method-model.json +230 -0
- package/server/taskmaster-templates/ai-research-position-paper.json +230 -0
- package/server/telemetry.js +121 -0
- package/server/templates/AGENTS.md +103 -0
- package/server/templates/CLAUDE.md +104 -0
- package/server/templates/GEMINI.md +103 -0
- package/server/templates/cursor-project.md +108 -0
- package/server/templates/index.js +37 -0
- package/server/utils/__tests__/sessionTokenUsage.test.js +82 -0
- package/server/utils/auto-research-mailer.js +57 -0
- package/server/utils/cliResolution.js +170 -0
- package/server/utils/commandParser.js +303 -0
- package/server/utils/cursorCommand.js +61 -0
- package/server/utils/geminiApiKey.js +28 -0
- package/server/utils/gitConfig.js +24 -0
- package/server/utils/imageAttachmentFiles.js +35 -0
- package/server/utils/mcp-detector.js +198 -0
- package/server/utils/parsers/bibtex-parser.js +207 -0
- package/server/utils/permissions.js +100 -0
- package/server/utils/runtimePorts.js +212 -0
- package/server/utils/sessionFormatting.js +68 -0
- package/server/utils/sessionIndex.js +100 -0
- package/server/utils/sessionMode.js +95 -0
- package/server/utils/sessionTokenUsage.js +105 -0
- package/server/utils/taskmaster-websocket.js +129 -0
- package/server/utils/zotero-client.js +136 -0
- package/shared/errorClassifier.js +102 -0
- package/shared/geminiThinkingSupport.d.ts +22 -0
- package/shared/geminiThinkingSupport.js +96 -0
- package/shared/geminiThoughtParser.d.ts +8 -0
- package/shared/geminiThoughtParser.js +30 -0
- package/shared/modelConstants.js +139 -0
|
@@ -0,0 +1,3973 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PROJECT DISCOVERY AND MANAGEMENT SYSTEM
|
|
3
|
+
* ========================================
|
|
4
|
+
*
|
|
5
|
+
* This module manages project discovery for both Claude CLI and Cursor CLI sessions.
|
|
6
|
+
*
|
|
7
|
+
* ## Architecture Overview
|
|
8
|
+
*
|
|
9
|
+
* 1. **Claude Projects** (stored in ~/.claude/projects/)
|
|
10
|
+
* - Each project is a directory named with the project path encoded (/ replaced with -)
|
|
11
|
+
* - Contains .jsonl files with conversation history including 'cwd' field
|
|
12
|
+
* - Project metadata stored in ~/.claude/project-config.json
|
|
13
|
+
*
|
|
14
|
+
* 2. **Cursor Projects** (stored in ~/.cursor/chats/)
|
|
15
|
+
* - Each project directory is named with MD5 hash of the absolute project path
|
|
16
|
+
* - Example: /Users/john/myproject -> MD5 -> a1b2c3d4e5f6...
|
|
17
|
+
* - Contains session directories with SQLite databases (store.db)
|
|
18
|
+
* - Project path is NOT stored in the database - only in the MD5 hash
|
|
19
|
+
*
|
|
20
|
+
* ## Project Discovery Strategy
|
|
21
|
+
*
|
|
22
|
+
* 1. **Claude Projects Discovery**:
|
|
23
|
+
* - Scan ~/.claude/projects/ directory for Claude project folders
|
|
24
|
+
* - Extract actual project path from .jsonl files (cwd field)
|
|
25
|
+
* - Fall back to decoded directory name if no sessions exist
|
|
26
|
+
*
|
|
27
|
+
* 2. **Cursor Sessions Discovery**:
|
|
28
|
+
* - For each KNOWN project (from Claude or manually added)
|
|
29
|
+
* - Compute MD5 hash of the project's absolute path
|
|
30
|
+
* - Check if ~/.cursor/chats/{md5_hash}/ directory exists
|
|
31
|
+
* - Read session metadata from SQLite store.db files
|
|
32
|
+
*
|
|
33
|
+
* 3. **Manual Project Addition**:
|
|
34
|
+
* - Users can manually add project paths via UI
|
|
35
|
+
* - Stored in ~/.claude/project-config.json with 'manuallyAdded' flag
|
|
36
|
+
* - Allows discovering Cursor sessions for projects without Claude sessions
|
|
37
|
+
*
|
|
38
|
+
* ## Critical Limitations
|
|
39
|
+
*
|
|
40
|
+
* - **CANNOT discover Cursor-only projects**: From a quick check, there was no mention of
|
|
41
|
+
* the cwd of each project. if someone has the time, you can try to reverse engineer it.
|
|
42
|
+
*
|
|
43
|
+
* - **Project relocation breaks history**: If a project directory is moved or renamed,
|
|
44
|
+
* the MD5 hash changes, making old Cursor sessions inaccessible unless the old
|
|
45
|
+
* path is known and manually added.
|
|
46
|
+
*
|
|
47
|
+
* ## Error Handling
|
|
48
|
+
*
|
|
49
|
+
* - Missing ~/.claude directory is handled gracefully with automatic creation
|
|
50
|
+
* - ENOENT errors are caught and handled without crashing
|
|
51
|
+
* - Empty arrays returned when no projects/sessions exist
|
|
52
|
+
*
|
|
53
|
+
* ## Caching Strategy
|
|
54
|
+
*
|
|
55
|
+
* - Project directory extraction is cached to minimize file I/O
|
|
56
|
+
* - Cache is cleared when project configuration changes
|
|
57
|
+
* - Session data is fetched on-demand, not cached
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
import { promises as fs } from 'fs';
|
|
61
|
+
import fsSync from 'fs';
|
|
62
|
+
import path from 'path';
|
|
63
|
+
import { fileURLToPath } from 'url';
|
|
64
|
+
import readline from 'readline';
|
|
65
|
+
import crypto from 'crypto';
|
|
66
|
+
import sqlite3 from 'sqlite3';
|
|
67
|
+
import { open } from 'sqlite';
|
|
68
|
+
import os from 'os';
|
|
69
|
+
import { stripInternalContextPrefix } from './utils/sessionFormatting.js';
|
|
70
|
+
import {
|
|
71
|
+
extractSessionModeFromMetadata,
|
|
72
|
+
extractSessionModeFromText,
|
|
73
|
+
inferSessionModeFromUserMessage,
|
|
74
|
+
normalizeSessionMode,
|
|
75
|
+
readExplicitSessionModeFromMetadata,
|
|
76
|
+
} from './utils/sessionMode.js';
|
|
77
|
+
|
|
78
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
79
|
+
const DRCLAW_SKILLS_DIR = path.join(__dirname, '..', 'skills');
|
|
80
|
+
const PROJECT_SKILL_FOLDERS = ['.claude', '.agents', '.cursor', '.gemini'];
|
|
81
|
+
const PROJECT_PIPELINE_FOLDERS = ['Survey', 'Ideation', 'Experiment', 'Publication', 'Promotion'];
|
|
82
|
+
const LEGACY_DEFAULT_WORKSPACES_ROOT = path.join(os.homedir(), 'vibelab');
|
|
83
|
+
const CURRENT_DEFAULT_WORKSPACES_ROOT = path.join(os.homedir(), 'dr-claw');
|
|
84
|
+
const DELETED_PROJECTS_CONFIG_KEY = '_deletedProjects';
|
|
85
|
+
|
|
86
|
+
let projectConfigMutationQueue = Promise.resolve();
|
|
87
|
+
|
|
88
|
+
function isProjectTrashed(projectInfo = null, dbEntry = null) {
|
|
89
|
+
return Boolean(projectInfo?.trash?.trashedAt || dbEntry?.metadata?.trash?.trashedAt);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getSuppressedProjectMetadata(projectName, config = null, projectInfo = null) {
|
|
93
|
+
return projectInfo?.deleted || config?.[DELETED_PROJECTS_CONFIG_KEY]?.[projectName] || null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isProjectSuppressed(projectName, config = null, projectInfo = null) {
|
|
97
|
+
return Boolean(getSuppressedProjectMetadata(projectName, config, projectInfo)?.deletedAt);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getProjectOwnerUserId(projectInfo = null, dbEntry = null) {
|
|
101
|
+
return dbEntry?.user_id
|
|
102
|
+
?? projectInfo?.ownerUserId
|
|
103
|
+
?? projectInfo?.trash?.ownerUserId
|
|
104
|
+
?? projectInfo?.deleted?.ownerUserId
|
|
105
|
+
?? null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getDeletedProjectsStore(config) {
|
|
109
|
+
if (!config[DELETED_PROJECTS_CONFIG_KEY] || typeof config[DELETED_PROJECTS_CONFIG_KEY] !== 'object') {
|
|
110
|
+
config[DELETED_PROJECTS_CONFIG_KEY] = {};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return config[DELETED_PROJECTS_CONFIG_KEY];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function clearDeletedProjectMetadata(config, projectName) {
|
|
117
|
+
if (!config?.[DELETED_PROJECTS_CONFIG_KEY]?.[projectName]) {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
delete config[DELETED_PROJECTS_CONFIG_KEY][projectName];
|
|
122
|
+
if (Object.keys(config[DELETED_PROJECTS_CONFIG_KEY]).length === 0) {
|
|
123
|
+
delete config[DELETED_PROJECTS_CONFIG_KEY];
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function readProjectInstanceId(projectPath) {
|
|
128
|
+
if (!projectPath) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const instanceRaw = await fs.readFile(path.join(projectPath, 'instance.json'), 'utf8');
|
|
134
|
+
const instanceData = JSON.parse(instanceRaw);
|
|
135
|
+
return typeof instanceData?.instance_id === 'string' && instanceData.instance_id.trim()
|
|
136
|
+
? instanceData.instance_id.trim()
|
|
137
|
+
: null;
|
|
138
|
+
} catch (_) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function mutateProjectConfig(mutator) {
|
|
144
|
+
const operation = projectConfigMutationQueue.then(async () => {
|
|
145
|
+
const config = await loadProjectConfig();
|
|
146
|
+
const result = await mutator(config);
|
|
147
|
+
await saveProjectConfig(config);
|
|
148
|
+
return result;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
projectConfigMutationQueue = operation.then(() => undefined, () => undefined);
|
|
152
|
+
return operation;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function pathExists(targetPath) {
|
|
156
|
+
if (!targetPath) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
await fs.access(targetPath);
|
|
162
|
+
return true;
|
|
163
|
+
} catch (_) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function bootstrapProjectsIndexFromLegacySources(config, projectDb, userId = null, visibleWorkspaceRoots = []) {
|
|
169
|
+
const candidateProjectNames = new Set(Object.keys(config).filter((key) => !key.startsWith('_')));
|
|
170
|
+
const claudeProjectsRoot = path.join(os.homedir(), '.claude', 'projects');
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
const entries = await fs.readdir(claudeProjectsRoot, { withFileTypes: true });
|
|
174
|
+
for (const entry of entries) {
|
|
175
|
+
if (entry.isDirectory()) {
|
|
176
|
+
candidateProjectNames.add(entry.name);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (error.code !== 'ENOENT') {
|
|
181
|
+
console.warn('[projects] Failed to read Claude projects for bootstrap:', error.message);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let seededCount = 0;
|
|
186
|
+
|
|
187
|
+
for (const projectName of candidateProjectNames) {
|
|
188
|
+
const projectInfo = config[projectName];
|
|
189
|
+
if (isProjectSuppressed(projectName, config, projectInfo)) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let projectPath = projectInfo?.originalPath || projectInfo?.path || null;
|
|
194
|
+
if (!projectPath) {
|
|
195
|
+
projectPath = await extractProjectDirectory(projectName);
|
|
196
|
+
}
|
|
197
|
+
if (!projectPath) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const isManuallyAdded = Boolean(projectInfo?.manuallyAdded);
|
|
202
|
+
if (!isManuallyAdded && visibleWorkspaceRoots.length > 0 && !await isPathWithinWorkspaceRoots(projectPath, visibleWorkspaceRoots)) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const existing = projectDb.getProjectById(projectName);
|
|
207
|
+
const ownerUserId = existing?.user_id ?? getProjectOwnerUserId(projectInfo, existing) ?? userId ?? null;
|
|
208
|
+
const metadata = { ...(existing?.metadata || {}) };
|
|
209
|
+
|
|
210
|
+
if (isManuallyAdded) {
|
|
211
|
+
metadata.manuallyAdded = true;
|
|
212
|
+
} else {
|
|
213
|
+
delete metadata.manuallyAdded;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (projectInfo?.trash?.trashedAt) {
|
|
217
|
+
metadata.trash = {
|
|
218
|
+
...projectInfo.trash,
|
|
219
|
+
ownerUserId: projectInfo.trash.ownerUserId ?? ownerUserId,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
projectDb.upsertProject(
|
|
224
|
+
projectName,
|
|
225
|
+
ownerUserId,
|
|
226
|
+
existing?.display_name || projectInfo?.displayName || null,
|
|
227
|
+
projectPath,
|
|
228
|
+
existing?.is_starred || 0,
|
|
229
|
+
existing?.last_accessed || null,
|
|
230
|
+
Object.keys(metadata).length > 0 ? metadata : null,
|
|
231
|
+
);
|
|
232
|
+
seededCount += 1;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return seededCount;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function buildTrashEntry(projectName, projectInfo = null, dbEntry = null) {
|
|
239
|
+
const trashMeta = dbEntry?.metadata?.trash || projectInfo?.trash;
|
|
240
|
+
if (!trashMeta?.trashedAt) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const filesExist = trashMeta.filesExist !== false;
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
name: projectName,
|
|
248
|
+
displayName: dbEntry?.display_name || projectInfo?.displayName || trashMeta.displayName || projectName,
|
|
249
|
+
fullPath: trashMeta.originalPath || dbEntry?.path || projectInfo?.originalPath || '',
|
|
250
|
+
path: trashMeta.originalPath || dbEntry?.path || projectInfo?.originalPath || '',
|
|
251
|
+
originalPath: trashMeta.originalPath || projectInfo?.originalPath || '',
|
|
252
|
+
trashPath: trashMeta.trashPath || dbEntry?.path || '',
|
|
253
|
+
claudeTrashPath: trashMeta.claudeTrashPath || '',
|
|
254
|
+
trashedAt: trashMeta.trashedAt,
|
|
255
|
+
sessionCount:
|
|
256
|
+
typeof trashMeta.sessionCount === 'number'
|
|
257
|
+
? trashMeta.sessionCount
|
|
258
|
+
: Array.isArray(dbEntry?.metadata?.sessions)
|
|
259
|
+
? dbEntry.metadata.sessions.length
|
|
260
|
+
: 0,
|
|
261
|
+
canRestore: Boolean(trashMeta.originalPath && filesExist),
|
|
262
|
+
filesExist,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function normalizeTaskStatus(status) {
|
|
267
|
+
const raw = String(status || '').trim().toLowerCase();
|
|
268
|
+
if (!raw) return 'pending';
|
|
269
|
+
if (raw === 'completed' || raw === 'complete') return 'done';
|
|
270
|
+
if (raw === 'in_progress' || raw === 'inprogress') return 'in-progress';
|
|
271
|
+
if (raw === 'todo' || raw === 'open') return 'pending';
|
|
272
|
+
return raw;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Import TaskMaster detection functions
|
|
276
|
+
async function detectTaskMasterFolder(projectPath) {
|
|
277
|
+
try {
|
|
278
|
+
const pipelinePath = path.join(projectPath, '.pipeline');
|
|
279
|
+
const legacyPath = path.join(projectPath, '.taskmaster');
|
|
280
|
+
let taskMasterPath = pipelinePath;
|
|
281
|
+
|
|
282
|
+
const hasPipeline = await fs.access(pipelinePath).then(() => true).catch(() => false);
|
|
283
|
+
if (!hasPipeline) {
|
|
284
|
+
const hasLegacy = await fs.access(legacyPath).then(() => true).catch(() => false);
|
|
285
|
+
if (hasLegacy) {
|
|
286
|
+
await fs.cp(legacyPath, pipelinePath, { recursive: true, force: false });
|
|
287
|
+
taskMasterPath = pipelinePath;
|
|
288
|
+
} else {
|
|
289
|
+
taskMasterPath = pipelinePath;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Check if .pipeline directory exists
|
|
294
|
+
try {
|
|
295
|
+
const stats = await fs.stat(taskMasterPath);
|
|
296
|
+
if (!stats.isDirectory()) {
|
|
297
|
+
return {
|
|
298
|
+
hasTaskmaster: false,
|
|
299
|
+
reason: '.pipeline exists but is not a directory'
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
} catch (error) {
|
|
303
|
+
if (error.code === 'ENOENT') {
|
|
304
|
+
return {
|
|
305
|
+
hasTaskmaster: false,
|
|
306
|
+
reason: '.pipeline directory not found'
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Check for key TaskMaster files
|
|
313
|
+
const keyFiles = [
|
|
314
|
+
'tasks/tasks.json',
|
|
315
|
+
'config.json'
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
const fileStatus = {};
|
|
319
|
+
let hasEssentialFiles = true;
|
|
320
|
+
|
|
321
|
+
for (const file of keyFiles) {
|
|
322
|
+
const filePath = path.join(taskMasterPath, file);
|
|
323
|
+
try {
|
|
324
|
+
await fs.access(filePath);
|
|
325
|
+
fileStatus[file] = true;
|
|
326
|
+
} catch (error) {
|
|
327
|
+
fileStatus[file] = false;
|
|
328
|
+
if (file === 'tasks/tasks.json') {
|
|
329
|
+
hasEssentialFiles = false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Parse tasks.json if it exists for metadata
|
|
335
|
+
let taskMetadata = null;
|
|
336
|
+
if (fileStatus['tasks/tasks.json']) {
|
|
337
|
+
try {
|
|
338
|
+
const tasksPath = path.join(taskMasterPath, 'tasks/tasks.json');
|
|
339
|
+
const tasksContent = await fs.readFile(tasksPath, 'utf8');
|
|
340
|
+
const tasksData = JSON.parse(tasksContent);
|
|
341
|
+
|
|
342
|
+
// Handle both tagged and legacy formats
|
|
343
|
+
let tasks = [];
|
|
344
|
+
if (tasksData.tasks) {
|
|
345
|
+
// Legacy format
|
|
346
|
+
tasks = tasksData.tasks;
|
|
347
|
+
} else {
|
|
348
|
+
// Tagged format - get tasks from all tags
|
|
349
|
+
Object.values(tasksData).forEach(tagData => {
|
|
350
|
+
if (tagData.tasks) {
|
|
351
|
+
tasks = tasks.concat(tagData.tasks);
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Calculate task statistics
|
|
357
|
+
const stats = tasks.reduce((acc, task) => {
|
|
358
|
+
const taskStatus = normalizeTaskStatus(task.status);
|
|
359
|
+
acc.total++;
|
|
360
|
+
acc[taskStatus] = (acc[taskStatus] || 0) + 1;
|
|
361
|
+
|
|
362
|
+
// Count subtasks
|
|
363
|
+
if (task.subtasks) {
|
|
364
|
+
task.subtasks.forEach(subtask => {
|
|
365
|
+
const subtaskStatus = normalizeTaskStatus(subtask.status);
|
|
366
|
+
acc.subtotalTasks++;
|
|
367
|
+
acc.subtasks = acc.subtasks || {};
|
|
368
|
+
acc.subtasks[subtaskStatus] = (acc.subtasks[subtaskStatus] || 0) + 1;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return acc;
|
|
373
|
+
}, {
|
|
374
|
+
total: 0,
|
|
375
|
+
subtotalTasks: 0,
|
|
376
|
+
pending: 0,
|
|
377
|
+
'in-progress': 0,
|
|
378
|
+
done: 0,
|
|
379
|
+
review: 0,
|
|
380
|
+
deferred: 0,
|
|
381
|
+
cancelled: 0,
|
|
382
|
+
subtasks: {}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
taskMetadata = {
|
|
386
|
+
taskCount: stats.total,
|
|
387
|
+
subtaskCount: stats.subtotalTasks,
|
|
388
|
+
completed: stats.done || 0,
|
|
389
|
+
pending: stats.pending || 0,
|
|
390
|
+
inProgress: stats['in-progress'] || 0,
|
|
391
|
+
review: stats.review || 0,
|
|
392
|
+
completionPercentage: stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0,
|
|
393
|
+
lastModified: (await fs.stat(tasksPath)).mtime.toISOString()
|
|
394
|
+
};
|
|
395
|
+
} catch (parseError) {
|
|
396
|
+
console.warn('Failed to parse tasks.json:', parseError.message);
|
|
397
|
+
taskMetadata = { error: 'Failed to parse tasks.json' };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return {
|
|
402
|
+
hasTaskmaster: true,
|
|
403
|
+
hasEssentialFiles,
|
|
404
|
+
files: fileStatus,
|
|
405
|
+
metadata: taskMetadata,
|
|
406
|
+
path: taskMasterPath
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
} catch (error) {
|
|
410
|
+
console.error('Error detecting TaskMaster folder:', error);
|
|
411
|
+
return {
|
|
412
|
+
hasTaskmaster: false,
|
|
413
|
+
reason: `Error checking directory: ${error.message}`
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Cache for extracted project directories
|
|
419
|
+
const projectDirectoryCache = new Map();
|
|
420
|
+
|
|
421
|
+
// Clear cache when needed (called when project files change)
|
|
422
|
+
function clearProjectDirectoryCache() {
|
|
423
|
+
projectDirectoryCache.clear();
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Load project configuration file
|
|
427
|
+
async function loadProjectConfig() {
|
|
428
|
+
const configPath = path.join(os.homedir(), '.claude', 'project-config.json');
|
|
429
|
+
try {
|
|
430
|
+
const configData = await fs.readFile(configPath, 'utf8');
|
|
431
|
+
return JSON.parse(configData);
|
|
432
|
+
} catch (error) {
|
|
433
|
+
// Return empty config if file doesn't exist
|
|
434
|
+
return {};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async function migrateLegacyDefaultWorkspacesRoot(targetRoot = CURRENT_DEFAULT_WORKSPACES_ROOT) {
|
|
439
|
+
if (targetRoot !== CURRENT_DEFAULT_WORKSPACES_ROOT) {
|
|
440
|
+
return targetRoot;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const legacyExists = fsSync.existsSync(LEGACY_DEFAULT_WORKSPACES_ROOT);
|
|
444
|
+
const currentExists = fsSync.existsSync(CURRENT_DEFAULT_WORKSPACES_ROOT);
|
|
445
|
+
|
|
446
|
+
if (!legacyExists || currentExists) {
|
|
447
|
+
return targetRoot;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
await fs.rename(LEGACY_DEFAULT_WORKSPACES_ROOT, CURRENT_DEFAULT_WORKSPACES_ROOT);
|
|
452
|
+
return CURRENT_DEFAULT_WORKSPACES_ROOT;
|
|
453
|
+
} catch (error) {
|
|
454
|
+
console.warn('[projects] Failed to migrate legacy default workspace root, using legacy path:', error.message);
|
|
455
|
+
return LEGACY_DEFAULT_WORKSPACES_ROOT;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function resolveConfiguredWorkspacesRoot(configRoot = null) {
|
|
460
|
+
if (!configRoot) {
|
|
461
|
+
return migrateLegacyDefaultWorkspacesRoot();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (configRoot === LEGACY_DEFAULT_WORKSPACES_ROOT) {
|
|
465
|
+
return migrateLegacyDefaultWorkspacesRoot();
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
return configRoot;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function normalizeWorkspaceRoots(roots) {
|
|
472
|
+
const normalizedRoots = [];
|
|
473
|
+
|
|
474
|
+
for (const root of roots) {
|
|
475
|
+
if (!root) continue;
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
const normalizedRoot = await normalizeComparablePath(root);
|
|
479
|
+
if (!normalizedRoots.includes(normalizedRoot)) {
|
|
480
|
+
normalizedRoots.push(normalizedRoot);
|
|
481
|
+
}
|
|
482
|
+
} catch (error) {
|
|
483
|
+
console.warn('[projects] Failed to normalize workspace root:', root, error.message);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return normalizedRoots;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function getVisibleWorkspaceRoots(configRoot = null) {
|
|
491
|
+
const resolvedRoot = process.env.WORKSPACES_ROOT || await resolveConfiguredWorkspacesRoot(configRoot);
|
|
492
|
+
const candidateRoots = [resolvedRoot];
|
|
493
|
+
|
|
494
|
+
const usesDefaultWorkspaceRoot =
|
|
495
|
+
!process.env.WORKSPACES_ROOT &&
|
|
496
|
+
(!configRoot ||
|
|
497
|
+
configRoot === LEGACY_DEFAULT_WORKSPACES_ROOT ||
|
|
498
|
+
configRoot === CURRENT_DEFAULT_WORKSPACES_ROOT ||
|
|
499
|
+
resolvedRoot === LEGACY_DEFAULT_WORKSPACES_ROOT ||
|
|
500
|
+
resolvedRoot === CURRENT_DEFAULT_WORKSPACES_ROOT);
|
|
501
|
+
|
|
502
|
+
if (usesDefaultWorkspaceRoot) {
|
|
503
|
+
candidateRoots.push(LEGACY_DEFAULT_WORKSPACES_ROOT);
|
|
504
|
+
candidateRoots.push(CURRENT_DEFAULT_WORKSPACES_ROOT);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return normalizeWorkspaceRoots(candidateRoots);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function isPathWithinWorkspaceRoots(candidatePath, normalizedRoots) {
|
|
511
|
+
const normalizedPath = await normalizeComparablePath(candidatePath);
|
|
512
|
+
return normalizedRoots.some((root) => normalizedPath === root || normalizedPath.startsWith(root + path.sep));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function remapLegacyProjectPath(projectPath) {
|
|
516
|
+
if (!projectPath) return null;
|
|
517
|
+
|
|
518
|
+
const normalizedPath = path.resolve(projectPath);
|
|
519
|
+
if (
|
|
520
|
+
normalizedPath !== LEGACY_DEFAULT_WORKSPACES_ROOT &&
|
|
521
|
+
!normalizedPath.startsWith(LEGACY_DEFAULT_WORKSPACES_ROOT + path.sep)
|
|
522
|
+
) {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return path.join(
|
|
527
|
+
CURRENT_DEFAULT_WORKSPACES_ROOT,
|
|
528
|
+
path.relative(LEGACY_DEFAULT_WORKSPACES_ROOT, normalizedPath)
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function remapCurrentProjectPathToLegacy(projectPath) {
|
|
533
|
+
if (!projectPath) return null;
|
|
534
|
+
|
|
535
|
+
const normalizedPath = path.resolve(projectPath);
|
|
536
|
+
if (
|
|
537
|
+
normalizedPath !== CURRENT_DEFAULT_WORKSPACES_ROOT &&
|
|
538
|
+
!normalizedPath.startsWith(CURRENT_DEFAULT_WORKSPACES_ROOT + path.sep)
|
|
539
|
+
) {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
return path.join(
|
|
544
|
+
LEGACY_DEFAULT_WORKSPACES_ROOT,
|
|
545
|
+
path.relative(CURRENT_DEFAULT_WORKSPACES_ROOT, normalizedPath)
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
async function maybeMigrateLegacyProject(projectName, projectInfo, projectDb) {
|
|
550
|
+
const legacyPath = projectInfo?.originalPath || projectInfo?.path;
|
|
551
|
+
const migratedPath = remapLegacyProjectPath(legacyPath);
|
|
552
|
+
|
|
553
|
+
if (!legacyPath || !migratedPath || migratedPath === legacyPath) {
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const legacyProjectId = projectName || encodeProjectPath(legacyPath);
|
|
558
|
+
const migratedProjectId = encodeProjectPath(migratedPath);
|
|
559
|
+
const legacyClaudeDir = path.join(os.homedir(), '.claude', 'projects', legacyProjectId);
|
|
560
|
+
const migratedClaudeDir = path.join(os.homedir(), '.claude', 'projects', migratedProjectId);
|
|
561
|
+
|
|
562
|
+
let legacyExists = false;
|
|
563
|
+
let migratedExists = false;
|
|
564
|
+
|
|
565
|
+
try {
|
|
566
|
+
await fs.access(legacyPath);
|
|
567
|
+
legacyExists = true;
|
|
568
|
+
} catch (_) {}
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
await fs.access(migratedPath);
|
|
572
|
+
migratedExists = true;
|
|
573
|
+
} catch (_) {}
|
|
574
|
+
|
|
575
|
+
if (legacyExists && !migratedExists) {
|
|
576
|
+
try {
|
|
577
|
+
await fs.mkdir(path.dirname(migratedPath), { recursive: true });
|
|
578
|
+
await fs.rename(legacyPath, migratedPath);
|
|
579
|
+
migratedExists = true;
|
|
580
|
+
legacyExists = false;
|
|
581
|
+
} catch (error) {
|
|
582
|
+
console.warn('[projects] Failed to move legacy project directory:', legacyPath, '->', migratedPath, error.message);
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
if (!migratedExists) {
|
|
588
|
+
return null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
try {
|
|
592
|
+
await fs.access(legacyClaudeDir);
|
|
593
|
+
try {
|
|
594
|
+
await fs.access(migratedClaudeDir);
|
|
595
|
+
} catch (_) {
|
|
596
|
+
await fs.rename(legacyClaudeDir, migratedClaudeDir);
|
|
597
|
+
}
|
|
598
|
+
} catch (_) {}
|
|
599
|
+
|
|
600
|
+
if (projectDb && legacyProjectId !== migratedProjectId) {
|
|
601
|
+
const existingMigratedProject = projectDb.getProjectById(migratedProjectId);
|
|
602
|
+
if (!existingMigratedProject) {
|
|
603
|
+
const existingLegacyProject = projectDb.getProjectById(legacyProjectId);
|
|
604
|
+
if (existingLegacyProject) {
|
|
605
|
+
projectDb.migrateProjectIdentity(legacyProjectId, migratedProjectId, migratedPath);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
} else if (projectDb) {
|
|
609
|
+
projectDb.updateProjectPath(migratedProjectId, migratedPath);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
oldId: legacyProjectId,
|
|
614
|
+
newId: migratedProjectId,
|
|
615
|
+
oldPath: legacyPath,
|
|
616
|
+
newPath: migratedPath
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async function migrateLegacyProjects(config, projectDb) {
|
|
621
|
+
let configDirty = false;
|
|
622
|
+
|
|
623
|
+
for (const [projectName, projectInfo] of Object.entries(config)) {
|
|
624
|
+
if (projectName.startsWith('_') || !projectInfo?.originalPath) {
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const migration = await maybeMigrateLegacyProject(projectName, projectInfo, projectDb);
|
|
629
|
+
if (!migration) {
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
const nextProjectInfo = {
|
|
634
|
+
...projectInfo,
|
|
635
|
+
originalPath: migration.newPath
|
|
636
|
+
};
|
|
637
|
+
|
|
638
|
+
if (migration.oldId !== migration.newId) {
|
|
639
|
+
if (!config[migration.newId]) {
|
|
640
|
+
config[migration.newId] = nextProjectInfo;
|
|
641
|
+
}
|
|
642
|
+
delete config[projectName];
|
|
643
|
+
} else {
|
|
644
|
+
config[projectName] = nextProjectInfo;
|
|
645
|
+
}
|
|
646
|
+
configDirty = true;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (configDirty) {
|
|
650
|
+
await saveProjectConfig(config);
|
|
651
|
+
clearProjectDirectoryCache();
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return configDirty;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Save project configuration file
|
|
658
|
+
async function saveProjectConfig(config) {
|
|
659
|
+
const claudeDir = path.join(os.homedir(), '.claude');
|
|
660
|
+
const configPath = path.join(claudeDir, 'project-config.json');
|
|
661
|
+
|
|
662
|
+
// Ensure the .claude directory exists
|
|
663
|
+
try {
|
|
664
|
+
await fs.mkdir(claudeDir, { recursive: true });
|
|
665
|
+
} catch (error) {
|
|
666
|
+
if (error.code !== 'EEXIST') {
|
|
667
|
+
throw error;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export function encodeProjectPath(projectPath) {
|
|
675
|
+
return path.resolve(projectPath).replace(/[\\/:\s~_.]/g, '-');
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Generate better display name from path
|
|
679
|
+
async function generateDisplayName(projectName, actualProjectDir = null) {
|
|
680
|
+
// Use actual project directory if provided, otherwise decode from project name
|
|
681
|
+
let projectPath = actualProjectDir || projectName.replace(/-/g, '/');
|
|
682
|
+
|
|
683
|
+
// Try to read package.json from the project path
|
|
684
|
+
try {
|
|
685
|
+
const packageJsonPath = path.join(projectPath, 'package.json');
|
|
686
|
+
const packageData = await fs.readFile(packageJsonPath, 'utf8');
|
|
687
|
+
const packageJson = JSON.parse(packageData);
|
|
688
|
+
|
|
689
|
+
// Return the name from package.json if it exists
|
|
690
|
+
if (packageJson.name) {
|
|
691
|
+
return packageJson.name;
|
|
692
|
+
}
|
|
693
|
+
} catch (error) {
|
|
694
|
+
// Fall back to path-based naming if package.json doesn't exist or can't be read
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// If it starts with /, it's an absolute path
|
|
698
|
+
if (projectPath.startsWith('/')) {
|
|
699
|
+
const parts = projectPath.split('/').filter(Boolean);
|
|
700
|
+
// Return only the last folder name
|
|
701
|
+
return parts[parts.length - 1] || projectPath;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return projectPath;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Extract the actual project directory from JSONL sessions (with caching)
|
|
708
|
+
async function extractProjectDirectory(projectName) {
|
|
709
|
+
// Check cache first
|
|
710
|
+
if (projectDirectoryCache.has(projectName)) {
|
|
711
|
+
return projectDirectoryCache.get(projectName);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Check project config for originalPath (manually added projects via UI or platform)
|
|
715
|
+
// This handles projects with dashes in their directory names correctly
|
|
716
|
+
const config = await loadProjectConfig();
|
|
717
|
+
if (config[projectName]?.originalPath) {
|
|
718
|
+
const originalPath = config[projectName].originalPath;
|
|
719
|
+
projectDirectoryCache.set(projectName, originalPath);
|
|
720
|
+
return originalPath;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
|
724
|
+
const cwdCounts = new Map();
|
|
725
|
+
let latestTimestamp = 0;
|
|
726
|
+
let latestCwd = null;
|
|
727
|
+
let extractedPath;
|
|
728
|
+
|
|
729
|
+
try {
|
|
730
|
+
// Check if the project directory exists
|
|
731
|
+
await fs.access(projectDir);
|
|
732
|
+
|
|
733
|
+
const files = await fs.readdir(projectDir);
|
|
734
|
+
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
|
|
735
|
+
|
|
736
|
+
if (jsonlFiles.length === 0) {
|
|
737
|
+
// Fall back to decoded project name if no sessions, but never to '/'
|
|
738
|
+
const decoded = projectName.replace(/-/g, '/');
|
|
739
|
+
extractedPath = decoded === '/' ? os.homedir() : decoded;
|
|
740
|
+
} else {
|
|
741
|
+
// Process all JSONL files to collect cwd values
|
|
742
|
+
for (const file of jsonlFiles) {
|
|
743
|
+
const jsonlFile = path.join(projectDir, file);
|
|
744
|
+
const fileStream = fsSync.createReadStream(jsonlFile);
|
|
745
|
+
const rl = readline.createInterface({
|
|
746
|
+
input: fileStream,
|
|
747
|
+
crlfDelay: Infinity
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
for await (const line of rl) {
|
|
751
|
+
if (line.trim()) {
|
|
752
|
+
try {
|
|
753
|
+
const entry = JSON.parse(line);
|
|
754
|
+
|
|
755
|
+
if (entry.cwd) {
|
|
756
|
+
// Count occurrences of each cwd
|
|
757
|
+
cwdCounts.set(entry.cwd, (cwdCounts.get(entry.cwd) || 0) + 1);
|
|
758
|
+
|
|
759
|
+
// Track the most recent cwd
|
|
760
|
+
const timestamp = new Date(entry.timestamp || 0).getTime();
|
|
761
|
+
if (timestamp > latestTimestamp) {
|
|
762
|
+
latestTimestamp = timestamp;
|
|
763
|
+
latestCwd = entry.cwd;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
} catch (parseError) {
|
|
767
|
+
// Skip malformed lines
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Determine the best cwd to use
|
|
774
|
+
if (cwdCounts.size === 0) {
|
|
775
|
+
// No cwd found, fall back to decoded project name, but never to '/'
|
|
776
|
+
const decoded = projectName.replace(/-/g, '/');
|
|
777
|
+
extractedPath = decoded === '/' ? os.homedir() : decoded;
|
|
778
|
+
} else if (cwdCounts.size === 1) {
|
|
779
|
+
// Only one cwd, use it
|
|
780
|
+
extractedPath = Array.from(cwdCounts.keys())[0];
|
|
781
|
+
} else {
|
|
782
|
+
// Multiple cwd values - prefer the most recent one if it has reasonable usage
|
|
783
|
+
const mostRecentCount = cwdCounts.get(latestCwd) || 0;
|
|
784
|
+
const maxCount = Math.max(...cwdCounts.values());
|
|
785
|
+
|
|
786
|
+
// Use most recent if it has at least 25% of the max count
|
|
787
|
+
if (mostRecentCount >= maxCount * 0.25) {
|
|
788
|
+
extractedPath = latestCwd;
|
|
789
|
+
} else {
|
|
790
|
+
// Otherwise use the most frequently used cwd
|
|
791
|
+
for (const [cwd, count] of cwdCounts.entries()) {
|
|
792
|
+
if (count === maxCount) {
|
|
793
|
+
extractedPath = cwd;
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Fallback (shouldn't reach here)
|
|
800
|
+
if (!extractedPath) {
|
|
801
|
+
const decoded = projectName.replace(/-/g, '/');
|
|
802
|
+
extractedPath = latestCwd || (decoded === '/' ? os.homedir() : decoded);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Cache the result
|
|
808
|
+
projectDirectoryCache.set(projectName, extractedPath);
|
|
809
|
+
|
|
810
|
+
return extractedPath;
|
|
811
|
+
|
|
812
|
+
} catch (error) {
|
|
813
|
+
// If the directory doesn't exist, just use the decoded project name
|
|
814
|
+
if (error.code === 'ENOENT') {
|
|
815
|
+
const decoded = projectName.replace(/-/g, '/');
|
|
816
|
+
extractedPath = decoded === '/' ? os.homedir() : decoded;
|
|
817
|
+
} else {
|
|
818
|
+
console.error(`Error extracting project directory for ${projectName}:`, error);
|
|
819
|
+
// Fall back to decoded project name for other errors, but never to '/'
|
|
820
|
+
const decoded = projectName.replace(/-/g, '/');
|
|
821
|
+
extractedPath = decoded === '/' ? os.homedir() : decoded;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Cache the fallback result too
|
|
825
|
+
projectDirectoryCache.set(projectName, extractedPath);
|
|
826
|
+
|
|
827
|
+
return extractedPath;
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function mapWithConcurrency(items, concurrency, mapper) {
|
|
832
|
+
const results = new Array(items.length);
|
|
833
|
+
let nextIndex = 0;
|
|
834
|
+
|
|
835
|
+
const worker = async () => {
|
|
836
|
+
while (true) {
|
|
837
|
+
const currentIndex = nextIndex;
|
|
838
|
+
nextIndex += 1;
|
|
839
|
+
|
|
840
|
+
if (currentIndex >= items.length) {
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
results[currentIndex] = await mapper(items[currentIndex], currentIndex);
|
|
845
|
+
}
|
|
846
|
+
};
|
|
847
|
+
|
|
848
|
+
const workerCount = Math.max(1, Math.min(concurrency, items.length || 1));
|
|
849
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
|
850
|
+
return results;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function mapIndexedSessionToProjectSession(session, provider) {
|
|
854
|
+
const metadata = session?.metadata && typeof session.metadata === 'object' ? session.metadata : {};
|
|
855
|
+
const mode = extractSessionModeFromMetadata(metadata);
|
|
856
|
+
const lastActivity = session?.last_activity || session?.lastActivity || session?.created_at || session?.createdAt || null;
|
|
857
|
+
const createdAt = session?.created_at || session?.createdAt || lastActivity;
|
|
858
|
+
const messageCount = Number(session?.message_count ?? session?.messageCount ?? 0);
|
|
859
|
+
const baseName = session?.display_name || session?.name || session?.summary || null;
|
|
860
|
+
const tags = Array.isArray(session?.tags) ? session.tags : [];
|
|
861
|
+
|
|
862
|
+
if (provider === 'cursor') {
|
|
863
|
+
return {
|
|
864
|
+
id: session.id,
|
|
865
|
+
name: baseName || 'Untitled Session',
|
|
866
|
+
createdAt,
|
|
867
|
+
lastActivity,
|
|
868
|
+
messageCount,
|
|
869
|
+
mode,
|
|
870
|
+
tags,
|
|
871
|
+
__provider: 'cursor',
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (provider === 'codex') {
|
|
876
|
+
return {
|
|
877
|
+
id: session.id,
|
|
878
|
+
summary: baseName || 'Codex Session',
|
|
879
|
+
name: baseName || 'Codex Session',
|
|
880
|
+
createdAt,
|
|
881
|
+
lastActivity,
|
|
882
|
+
messageCount,
|
|
883
|
+
mode,
|
|
884
|
+
tags,
|
|
885
|
+
__provider: 'codex',
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (provider === 'gemini') {
|
|
890
|
+
return {
|
|
891
|
+
id: session.id,
|
|
892
|
+
summary: baseName || 'Gemini Session',
|
|
893
|
+
name: baseName || 'Gemini Session',
|
|
894
|
+
createdAt,
|
|
895
|
+
lastActivity,
|
|
896
|
+
messageCount,
|
|
897
|
+
mode,
|
|
898
|
+
tags,
|
|
899
|
+
__provider: 'gemini',
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (provider === 'openrouter') {
|
|
904
|
+
return {
|
|
905
|
+
id: session.id,
|
|
906
|
+
summary: baseName || 'OpenRouter Session',
|
|
907
|
+
name: baseName || 'OpenRouter Session',
|
|
908
|
+
createdAt,
|
|
909
|
+
lastActivity,
|
|
910
|
+
messageCount,
|
|
911
|
+
mode,
|
|
912
|
+
tags,
|
|
913
|
+
__provider: 'openrouter',
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
return {
|
|
918
|
+
id: session.id,
|
|
919
|
+
summary: baseName || 'New Session',
|
|
920
|
+
createdAt,
|
|
921
|
+
lastActivity,
|
|
922
|
+
messageCount,
|
|
923
|
+
mode,
|
|
924
|
+
tags,
|
|
925
|
+
__provider: 'claude',
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function getSessionPlaceholderName(provider) {
|
|
930
|
+
switch (provider) {
|
|
931
|
+
case 'cursor':
|
|
932
|
+
return 'Untitled Session';
|
|
933
|
+
case 'codex':
|
|
934
|
+
return 'Codex Session';
|
|
935
|
+
case 'gemini':
|
|
936
|
+
return 'Gemini Session';
|
|
937
|
+
case 'openrouter':
|
|
938
|
+
return 'OpenRouter Session';
|
|
939
|
+
default:
|
|
940
|
+
return 'New Session';
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function isPlaceholderSessionName(provider, displayName) {
|
|
945
|
+
return String(displayName || '').trim() === getSessionPlaceholderName(provider);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
async function shouldRefreshIndexedSession(provider, indexedSession, parsedSession) {
|
|
949
|
+
if (!parsedSession) {
|
|
950
|
+
return false;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (!indexedSession) {
|
|
954
|
+
return true;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const indexedName = String(indexedSession.display_name || indexedSession.name || indexedSession.summary || '').trim();
|
|
958
|
+
const parsedName = String(parsedSession.summary || parsedSession.name || '').trim();
|
|
959
|
+
if (parsedName && indexedName !== parsedName) {
|
|
960
|
+
return true;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const indexedCount = Number(indexedSession.message_count ?? indexedSession.messageCount ?? 0);
|
|
964
|
+
const parsedCount = Number(parsedSession.messageCount ?? 0);
|
|
965
|
+
if (parsedCount > indexedCount) {
|
|
966
|
+
return true;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const { normalizeSessionTimestamp } = await import('./database/db.js');
|
|
970
|
+
const indexedLastActivity = normalizeSessionTimestamp(indexedSession.last_activity || indexedSession.lastActivity);
|
|
971
|
+
const parsedLastActivity = normalizeSessionTimestamp(parsedSession.lastActivity);
|
|
972
|
+
if (parsedLastActivity && parsedLastActivity !== indexedLastActivity) {
|
|
973
|
+
return true;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const indexedMode = extractSessionModeFromMetadata(indexedSession.metadata);
|
|
977
|
+
const parsedMode = normalizeSessionMode(parsedSession.mode);
|
|
978
|
+
if (indexedMode !== parsedMode) {
|
|
979
|
+
return true;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
return isPlaceholderSessionName(provider, indexedName) && Boolean(parsedName);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
async function reconcileIndexedSessionFromSource(projectName, provider, parsedSession, indexedSession = null, projectPath = null) {
|
|
986
|
+
const { sessionDb, normalizeSessionTimestamp } = await import('./database/db.js');
|
|
987
|
+
|
|
988
|
+
const resolvedProjectPath =
|
|
989
|
+
projectPath ||
|
|
990
|
+
parsedSession.projectPath ||
|
|
991
|
+
parsedSession.cwd ||
|
|
992
|
+
indexedSession?.metadata?.projectPath ||
|
|
993
|
+
await extractProjectDirectory(projectName).catch(() => null);
|
|
994
|
+
const metadata = {
|
|
995
|
+
...(indexedSession?.metadata && typeof indexedSession.metadata === 'object' ? indexedSession.metadata : {}),
|
|
996
|
+
sessionMode: normalizeSessionMode(parsedSession.mode),
|
|
997
|
+
indexState: 'synced',
|
|
998
|
+
};
|
|
999
|
+
if (resolvedProjectPath) {
|
|
1000
|
+
metadata.projectPath = resolvedProjectPath;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
sessionDb.upsertSessionFromSource(parsedSession.id, projectName, provider, {
|
|
1004
|
+
displayName: parsedSession.summary || parsedSession.name || null,
|
|
1005
|
+
lastActivity: normalizeSessionTimestamp(parsedSession.lastActivity),
|
|
1006
|
+
messageCount: Number(parsedSession.messageCount || 0),
|
|
1007
|
+
metadata,
|
|
1008
|
+
createdAt: parsedSession.createdAt || indexedSession?.created_at || null,
|
|
1009
|
+
isStarred: indexedSession?.is_starred ?? 0,
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
async function reconcileClaudeSessionIndex(projectName, targetSessionId = null) {
|
|
1014
|
+
if (targetSessionId) {
|
|
1015
|
+
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
|
1016
|
+
const sessionFile = path.join(projectDir, `${targetSessionId}.jsonl`);
|
|
1017
|
+
const { sessionDb } = await import('./database/db.js');
|
|
1018
|
+
|
|
1019
|
+
try {
|
|
1020
|
+
await fs.access(sessionFile);
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
if (error?.code === 'ENOENT') {
|
|
1023
|
+
return { sessions: [], hasMore: false, total: 0, session: null };
|
|
1024
|
+
}
|
|
1025
|
+
throw error;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const dbSessions = sessionDb.getSessionsByProject(projectName);
|
|
1029
|
+
const dbSessionMap = new Map(dbSessions.filter((session) => session.provider === 'claude').map((session) => [session.id, session]));
|
|
1030
|
+
const projectPath = await extractProjectDirectory(projectName).catch(() => null);
|
|
1031
|
+
const result = await parseJsonlSessions(sessionFile, projectName, dbSessionMap);
|
|
1032
|
+
const session = (result.sessions || []).find((item) => item.id === targetSessionId) || null;
|
|
1033
|
+
|
|
1034
|
+
if (session) {
|
|
1035
|
+
const indexedSession = dbSessionMap.get(session.id) || null;
|
|
1036
|
+
if (await shouldRefreshIndexedSession('claude', indexedSession, session)) {
|
|
1037
|
+
await reconcileIndexedSessionFromSource(projectName, 'claude', session, indexedSession, projectPath);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
return {
|
|
1042
|
+
sessions: session ? [session] : [],
|
|
1043
|
+
hasMore: false,
|
|
1044
|
+
total: session ? 1 : 0,
|
|
1045
|
+
session,
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
return getSessions(projectName, 0, 0);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
async function reconcileGeminiSessionIndex(projectPath, options = {}) {
|
|
1053
|
+
const { limit = 0, sessionId = null, projectName = null } = options;
|
|
1054
|
+
return getGeminiSessions(projectPath, {
|
|
1055
|
+
limit,
|
|
1056
|
+
syncIndex: true,
|
|
1057
|
+
sessionId,
|
|
1058
|
+
projectName,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
async function reconcileOpenRouterSessionIndex(projectPath, options = {}) {
|
|
1063
|
+
const { sessionId = null, projectName = null } = options;
|
|
1064
|
+
if (!sessionId) return;
|
|
1065
|
+
const resolvedProjectName = projectName || encodeProjectPath(projectPath);
|
|
1066
|
+
const sessionFile = path.join(os.homedir(), '.dr-claw', 'openrouter-sessions', `${sessionId}.jsonl`);
|
|
1067
|
+
try {
|
|
1068
|
+
const raw = await fs.readFile(sessionFile, 'utf-8');
|
|
1069
|
+
const lines = raw.trim().split('\n').filter(Boolean);
|
|
1070
|
+
let displayName = null;
|
|
1071
|
+
let messageCount = 0;
|
|
1072
|
+
let lastActivity = null;
|
|
1073
|
+
for (const line of lines) {
|
|
1074
|
+
try {
|
|
1075
|
+
const entry = JSON.parse(line);
|
|
1076
|
+
if (entry.role === 'user' && !displayName) {
|
|
1077
|
+
const raw = (entry.content || '').replace(/\s*\[Context:[^\]]*\]\s*/gi, '').trim();
|
|
1078
|
+
displayName = raw.slice(0, 100) || null;
|
|
1079
|
+
}
|
|
1080
|
+
if (entry.role === 'user' || entry.role === 'assistant') {
|
|
1081
|
+
messageCount++;
|
|
1082
|
+
}
|
|
1083
|
+
if (entry.ts) lastActivity = entry.ts;
|
|
1084
|
+
} catch {}
|
|
1085
|
+
}
|
|
1086
|
+
const { sessionDb } = await import('./database/db.js');
|
|
1087
|
+
sessionDb.upsertSession(
|
|
1088
|
+
sessionId,
|
|
1089
|
+
resolvedProjectName,
|
|
1090
|
+
'openrouter',
|
|
1091
|
+
displayName || 'OpenRouter Session',
|
|
1092
|
+
lastActivity || new Date().toISOString(),
|
|
1093
|
+
messageCount,
|
|
1094
|
+
null,
|
|
1095
|
+
);
|
|
1096
|
+
} catch (err) {
|
|
1097
|
+
console.warn(`[OpenRouter] Failed to reconcile session ${sessionId}:`, err.message);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
async function reconcileLocalGPUSessionIndex(projectPath, options = {}) {
|
|
1102
|
+
const { sessionId = null, projectName = null } = options;
|
|
1103
|
+
if (!sessionId) return;
|
|
1104
|
+
const resolvedProjectName = projectName || encodeProjectPath(projectPath);
|
|
1105
|
+
const sessionFile = path.join(os.homedir(), '.dr-claw', 'localgpu-sessions', `${sessionId}.jsonl`);
|
|
1106
|
+
try {
|
|
1107
|
+
const raw = await fs.readFile(sessionFile, 'utf-8');
|
|
1108
|
+
const lines = raw.trim().split('\n').filter(Boolean);
|
|
1109
|
+
let displayName = null;
|
|
1110
|
+
let messageCount = 0;
|
|
1111
|
+
let lastActivity = null;
|
|
1112
|
+
for (const line of lines) {
|
|
1113
|
+
try {
|
|
1114
|
+
const entry = JSON.parse(line);
|
|
1115
|
+
if (entry.role === 'user' && !displayName) {
|
|
1116
|
+
const raw = (entry.content || '').replace(/\s*\[Context:[^\]]*\]\s*/gi, '').trim();
|
|
1117
|
+
displayName = raw.slice(0, 100) || null;
|
|
1118
|
+
}
|
|
1119
|
+
if (entry.role === 'user' || entry.role === 'assistant') {
|
|
1120
|
+
messageCount++;
|
|
1121
|
+
}
|
|
1122
|
+
if (entry.ts) lastActivity = entry.ts;
|
|
1123
|
+
} catch {}
|
|
1124
|
+
}
|
|
1125
|
+
const { sessionDb } = await import('./database/db.js');
|
|
1126
|
+
sessionDb.upsertSession(
|
|
1127
|
+
sessionId,
|
|
1128
|
+
resolvedProjectName,
|
|
1129
|
+
'local',
|
|
1130
|
+
displayName || 'Local GPU Session',
|
|
1131
|
+
lastActivity || new Date().toISOString(),
|
|
1132
|
+
messageCount,
|
|
1133
|
+
null,
|
|
1134
|
+
);
|
|
1135
|
+
} catch (err) {
|
|
1136
|
+
console.warn(`[LocalGPU] Failed to reconcile session ${sessionId}:`, err.message);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
async function reconcileCodexSessionIndex(projectPath, options = {}) {
|
|
1141
|
+
const { limit = 0, sessionId = null, previousSessionId = null, projectName = null } = options;
|
|
1142
|
+
const sessions = await getCodexSessions(projectPath, {
|
|
1143
|
+
limit,
|
|
1144
|
+
syncIndex: true,
|
|
1145
|
+
sessionId,
|
|
1146
|
+
projectName,
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1149
|
+
if (previousSessionId && sessionId && previousSessionId !== sessionId) {
|
|
1150
|
+
const { sessionDb } = await import('./database/db.js');
|
|
1151
|
+
sessionDb.migrateSessionId(previousSessionId, sessionId, 'codex', projectName || encodeProjectPath(projectPath));
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return sessions;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
async function getProjects(userId, progressCallback = null) {
|
|
1158
|
+
const { projectDb, sessionDb } = await import('./database/db.js');
|
|
1159
|
+
const config = await loadProjectConfig();
|
|
1160
|
+
const projects = [];
|
|
1161
|
+
|
|
1162
|
+
await migrateLegacyProjects(config, projectDb);
|
|
1163
|
+
|
|
1164
|
+
const visibleWorkspaceRoots = await getVisibleWorkspaceRoots(config._workspacesRoot || null);
|
|
1165
|
+
let totalProjects = 0;
|
|
1166
|
+
let processedProjects = 0;
|
|
1167
|
+
|
|
1168
|
+
let dbProjects = projectDb.getAllProjects(userId || null);
|
|
1169
|
+
if (dbProjects.length === 0) {
|
|
1170
|
+
const seededCount = await bootstrapProjectsIndexFromLegacySources(
|
|
1171
|
+
config,
|
|
1172
|
+
projectDb,
|
|
1173
|
+
userId || null,
|
|
1174
|
+
visibleWorkspaceRoots,
|
|
1175
|
+
);
|
|
1176
|
+
if (seededCount > 0) {
|
|
1177
|
+
dbProjects = projectDb.getAllProjects(userId || null);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
try {
|
|
1182
|
+
const visibleProjects = [];
|
|
1183
|
+
for (const dbEntry of dbProjects) {
|
|
1184
|
+
const projectInfo = config[dbEntry.id];
|
|
1185
|
+
if (isProjectTrashed(projectInfo, dbEntry) || isProjectSuppressed(dbEntry.id, config, projectInfo)) {
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
const projectPath = dbEntry.path || projectInfo?.originalPath || null;
|
|
1190
|
+
if (!projectPath) {
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const isManuallyAdded = Boolean(dbEntry.metadata?.manuallyAdded || projectInfo?.manuallyAdded);
|
|
1195
|
+
if (!isManuallyAdded && !await isPathWithinWorkspaceRoots(projectPath, visibleWorkspaceRoots)) {
|
|
1196
|
+
console.log(`[projects] Skipping external DB project: ${dbEntry.id} at ${projectPath}`);
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
visibleProjects.push({
|
|
1201
|
+
entry: { name: dbEntry.id },
|
|
1202
|
+
actualProjectDir: projectPath,
|
|
1203
|
+
dbEntry,
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
const projectNames = visibleProjects.map(({ entry }) => entry.name);
|
|
1208
|
+
const indexedSessions = sessionDb.getSessionsByProjects(projectNames);
|
|
1209
|
+
const sessionsByProject = new Map();
|
|
1210
|
+
|
|
1211
|
+
for (const session of indexedSessions) {
|
|
1212
|
+
if (!sessionsByProject.has(session.project_name)) {
|
|
1213
|
+
sessionsByProject.set(session.project_name, []);
|
|
1214
|
+
}
|
|
1215
|
+
sessionsByProject.get(session.project_name).push(session);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
totalProjects = visibleProjects.length;
|
|
1219
|
+
|
|
1220
|
+
const hydratedProjects = await mapWithConcurrency(visibleProjects, 6, async ({ entry, actualProjectDir, dbEntry }) => {
|
|
1221
|
+
processedProjects++;
|
|
1222
|
+
|
|
1223
|
+
if (progressCallback) {
|
|
1224
|
+
progressCallback({ phase: 'loading', current: processedProjects, total: totalProjects, currentProject: entry.name });
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
const projectInfo = config[entry.name];
|
|
1228
|
+
const displayName = dbEntry?.display_name || projectInfo?.displayName || await generateDisplayName(entry.name, actualProjectDir);
|
|
1229
|
+
|
|
1230
|
+
let dirCreatedAt = dbEntry?.created_at;
|
|
1231
|
+
if (!dirCreatedAt) {
|
|
1232
|
+
try {
|
|
1233
|
+
const dirStat = await fs.stat(actualProjectDir);
|
|
1234
|
+
dirCreatedAt = dirStat.birthtime.toISOString();
|
|
1235
|
+
} catch (_) {}
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
const project = {
|
|
1239
|
+
name: entry.name,
|
|
1240
|
+
path: actualProjectDir,
|
|
1241
|
+
displayName,
|
|
1242
|
+
fullPath: actualProjectDir,
|
|
1243
|
+
isCustomName: !!(dbEntry?.display_name || projectInfo?.displayName),
|
|
1244
|
+
createdAt: dirCreatedAt,
|
|
1245
|
+
isStarred: !!dbEntry?.is_starred,
|
|
1246
|
+
sessions: [],
|
|
1247
|
+
sessionMeta: { hasMore: false, total: 0 }
|
|
1248
|
+
};
|
|
1249
|
+
|
|
1250
|
+
const projectSessions = sessionsByProject.get(entry.name) || [];
|
|
1251
|
+
const claudeSessions = projectSessions.filter((session) => session.provider === 'claude');
|
|
1252
|
+
const cursorSessions = projectSessions.filter((session) => session.provider === 'cursor');
|
|
1253
|
+
const codexSessions = projectSessions.filter((session) => session.provider === 'codex');
|
|
1254
|
+
const geminiSessions = projectSessions.filter((session) => session.provider === 'gemini');
|
|
1255
|
+
const openrouterSessions = projectSessions.filter((session) => session.provider === 'openrouter');
|
|
1256
|
+
const localSessions = projectSessions.filter((session) => session.provider === 'local');
|
|
1257
|
+
|
|
1258
|
+
project.sessions = claudeSessions.slice(0, 5).map((session) => mapIndexedSessionToProjectSession(session, 'claude'));
|
|
1259
|
+
project.sessionMeta = {
|
|
1260
|
+
total: claudeSessions.length,
|
|
1261
|
+
hasMore: claudeSessions.length > 5,
|
|
1262
|
+
};
|
|
1263
|
+
project.cursorSessions = cursorSessions.slice(0, 5).map((session) => mapIndexedSessionToProjectSession(session, 'cursor'));
|
|
1264
|
+
project.codexSessions = codexSessions.slice(0, 5).map((session) => mapIndexedSessionToProjectSession(session, 'codex'));
|
|
1265
|
+
project.geminiSessions = geminiSessions.slice(0, 5).map((session) => mapIndexedSessionToProjectSession(session, 'gemini'));
|
|
1266
|
+
project.openrouterSessions = openrouterSessions.slice(0, 5).map((session) => mapIndexedSessionToProjectSession(session, 'openrouter'));
|
|
1267
|
+
project.localSessions = localSessions.slice(0, 5).map((session) => mapIndexedSessionToProjectSession(session, 'local'));
|
|
1268
|
+
|
|
1269
|
+
const taskmasterResult = await detectTaskMasterFolder(actualProjectDir).catch(() => null);
|
|
1270
|
+
|
|
1271
|
+
if (taskmasterResult) {
|
|
1272
|
+
const tm = taskmasterResult;
|
|
1273
|
+
project.taskmaster = {
|
|
1274
|
+
hasTaskmaster: tm.hasTaskmaster,
|
|
1275
|
+
hasEssentialFiles: tm.hasEssentialFiles,
|
|
1276
|
+
metadata: tm.metadata,
|
|
1277
|
+
status: tm.hasTaskmaster && tm.hasEssentialFiles ? 'configured' : 'not-configured'
|
|
1278
|
+
};
|
|
1279
|
+
project.pipeline = project.taskmaster;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
return project;
|
|
1283
|
+
});
|
|
1284
|
+
|
|
1285
|
+
projects.push(...hydratedProjects.filter(Boolean));
|
|
1286
|
+
} catch (error) {
|
|
1287
|
+
console.error('Error reading projects from database:', error);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
return projects;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
async function getTrashedProjects(userId = null) {
|
|
1294
|
+
const { projectDb } = await import('./database/db.js');
|
|
1295
|
+
const config = await loadProjectConfig();
|
|
1296
|
+
const allDbProjects = projectDb.getAllProjects();
|
|
1297
|
+
const dbProjectMap = new Map(allDbProjects.map((entry) => [entry.id, entry]));
|
|
1298
|
+
const allProjectNames = new Set([
|
|
1299
|
+
...Object.keys(config).filter((key) => !key.startsWith('_')),
|
|
1300
|
+
...allDbProjects.map((entry) => entry.id),
|
|
1301
|
+
]);
|
|
1302
|
+
|
|
1303
|
+
const trashEntries = [];
|
|
1304
|
+
|
|
1305
|
+
for (const projectName of allProjectNames) {
|
|
1306
|
+
const projectInfo = config[projectName];
|
|
1307
|
+
const dbEntry = dbProjectMap.get(projectName);
|
|
1308
|
+
|
|
1309
|
+
if (!isProjectTrashed(projectInfo, dbEntry)) {
|
|
1310
|
+
continue;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const ownerUserId = getProjectOwnerUserId(projectInfo, dbEntry);
|
|
1314
|
+
if (userId && ownerUserId !== userId) {
|
|
1315
|
+
continue;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
const trashEntry = buildTrashEntry(projectName, projectInfo, dbEntry);
|
|
1319
|
+
if (trashEntry) {
|
|
1320
|
+
trashEntries.push(trashEntry);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
return trashEntries.sort(
|
|
1325
|
+
(left, right) => new Date(right.trashedAt).getTime() - new Date(left.trashedAt).getTime(),
|
|
1326
|
+
);
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
async function getSessions(projectName, limit = 5, offset = 0, userId = null) {
|
|
1330
|
+
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
|
1331
|
+
const { sessionDb } = await import('./database/db.js');
|
|
1332
|
+
|
|
1333
|
+
try {
|
|
1334
|
+
// Check if the project directory exists before trying to read it
|
|
1335
|
+
try {
|
|
1336
|
+
await fs.access(projectDir);
|
|
1337
|
+
} catch (err) {
|
|
1338
|
+
if (err.code === 'ENOENT') {
|
|
1339
|
+
// No Claude sessions for this project yet, which is fine for manual projects
|
|
1340
|
+
return { sessions: [], hasMore: false, total: 0 };
|
|
1341
|
+
}
|
|
1342
|
+
throw err;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const files = await fs.readdir(projectDir);
|
|
1346
|
+
const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
|
|
1347
|
+
|
|
1348
|
+
if (jsonlFiles.length === 0) {
|
|
1349
|
+
return { sessions: [], hasMore: false, total: 0 };
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// Fetch indexed sessions from database - filter by userId?
|
|
1353
|
+
// Usually sessions inherit project ownership, but we store it anyway.
|
|
1354
|
+
const dbSessions = sessionDb.getSessionsByProject(projectName);
|
|
1355
|
+
const dbSessionMap = new Map(dbSessions.filter(s => s.provider === 'claude').map(s => [s.id, s]));
|
|
1356
|
+
const projectPath = await extractProjectDirectory(projectName).catch(() => null);
|
|
1357
|
+
|
|
1358
|
+
// ... (rest of getSessions remains mostly same, but ensures it uses the DB map correctly)
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
// Sort files by modification time (newest first)
|
|
1362
|
+
const filesWithStats = await Promise.all(
|
|
1363
|
+
jsonlFiles.map(async (file) => {
|
|
1364
|
+
const filePath = path.join(projectDir, file);
|
|
1365
|
+
const stats = await fs.stat(filePath);
|
|
1366
|
+
return { file, mtime: stats.mtime };
|
|
1367
|
+
})
|
|
1368
|
+
);
|
|
1369
|
+
filesWithStats.sort((a, b) => b.mtime - a.mtime);
|
|
1370
|
+
|
|
1371
|
+
const allSessions = new Map();
|
|
1372
|
+
const allEntries = [];
|
|
1373
|
+
const uuidToSessionMap = new Map();
|
|
1374
|
+
|
|
1375
|
+
// Collect all sessions and entries from all files
|
|
1376
|
+
for (const { file } of filesWithStats) {
|
|
1377
|
+
const jsonlFile = path.join(projectDir, file);
|
|
1378
|
+
const result = await parseJsonlSessions(jsonlFile, projectName, dbSessionMap);
|
|
1379
|
+
|
|
1380
|
+
result.sessions.forEach(session => {
|
|
1381
|
+
if (!allSessions.has(session.id)) {
|
|
1382
|
+
allSessions.set(session.id, session);
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
allEntries.push(...result.entries);
|
|
1387
|
+
|
|
1388
|
+
// Early exit optimization for large projects
|
|
1389
|
+
if (allSessions.size >= (limit + offset) * 2 && allEntries.length >= Math.min(3, filesWithStats.length)) {
|
|
1390
|
+
break;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
// Build UUID-to-session mapping for timeline detection
|
|
1395
|
+
allEntries.forEach(entry => {
|
|
1396
|
+
if (entry.uuid && entry.sessionId) {
|
|
1397
|
+
uuidToSessionMap.set(entry.uuid, entry.sessionId);
|
|
1398
|
+
}
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
// Group sessions by first user message ID
|
|
1402
|
+
const sessionGroups = new Map(); // firstUserMsgId -> { latestSession, allSessions[] }
|
|
1403
|
+
const sessionToFirstUserMsgId = new Map(); // sessionId -> firstUserMsgId
|
|
1404
|
+
|
|
1405
|
+
// Find the first user message for each session
|
|
1406
|
+
allEntries.forEach(entry => {
|
|
1407
|
+
if (entry.sessionId && entry.type === 'user' && entry.parentUuid === null && entry.uuid) {
|
|
1408
|
+
// This is a first user message in a session (parentUuid is null)
|
|
1409
|
+
const firstUserMsgId = entry.uuid;
|
|
1410
|
+
|
|
1411
|
+
if (!sessionToFirstUserMsgId.has(entry.sessionId)) {
|
|
1412
|
+
sessionToFirstUserMsgId.set(entry.sessionId, firstUserMsgId);
|
|
1413
|
+
|
|
1414
|
+
const session = allSessions.get(entry.sessionId);
|
|
1415
|
+
if (session) {
|
|
1416
|
+
if (!sessionGroups.has(firstUserMsgId)) {
|
|
1417
|
+
sessionGroups.set(firstUserMsgId, {
|
|
1418
|
+
latestSession: session,
|
|
1419
|
+
allSessions: [session]
|
|
1420
|
+
});
|
|
1421
|
+
} else {
|
|
1422
|
+
const group = sessionGroups.get(firstUserMsgId);
|
|
1423
|
+
group.allSessions.push(session);
|
|
1424
|
+
|
|
1425
|
+
// Update latest session if this one is more recent
|
|
1426
|
+
if (new Date(session.lastActivity) > new Date(group.latestSession.lastActivity)) {
|
|
1427
|
+
group.latestSession = session;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
// Collect all sessions that don't belong to any group (standalone sessions)
|
|
1436
|
+
const groupedSessionIds = new Set();
|
|
1437
|
+
sessionGroups.forEach(group => {
|
|
1438
|
+
group.allSessions.forEach(session => groupedSessionIds.add(session.id));
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
const standaloneSessionsArray = Array.from(allSessions.values())
|
|
1442
|
+
.filter(session => !groupedSessionIds.has(session.id));
|
|
1443
|
+
|
|
1444
|
+
// Combine grouped sessions (only show latest from each group) + standalone sessions
|
|
1445
|
+
const latestFromGroups = Array.from(sessionGroups.values()).map(group => {
|
|
1446
|
+
const session = { ...group.latestSession };
|
|
1447
|
+
// Add metadata about grouping
|
|
1448
|
+
if (group.allSessions.length > 1) {
|
|
1449
|
+
session.isGrouped = true;
|
|
1450
|
+
session.groupSize = group.allSessions.length;
|
|
1451
|
+
session.groupSessions = group.allSessions.map(s => s.id);
|
|
1452
|
+
}
|
|
1453
|
+
return session;
|
|
1454
|
+
});
|
|
1455
|
+
const visibleSessions = [...latestFromGroups, ...standaloneSessionsArray]
|
|
1456
|
+
.filter(session => !session.summary.startsWith('{ "'))
|
|
1457
|
+
.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
|
|
1458
|
+
|
|
1459
|
+
await Promise.all(
|
|
1460
|
+
visibleSessions.map(async (session) => {
|
|
1461
|
+
const indexedSession = dbSessionMap.get(session.id) || null;
|
|
1462
|
+
if (!await shouldRefreshIndexedSession('claude', indexedSession, session)) {
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
await reconcileIndexedSessionFromSource(projectName, 'claude', session, indexedSession, projectPath);
|
|
1467
|
+
})
|
|
1468
|
+
);
|
|
1469
|
+
|
|
1470
|
+
const total = visibleSessions.length;
|
|
1471
|
+
const paginatedSessions = limit > 0 ? visibleSessions.slice(offset, offset + limit) : visibleSessions.slice(offset);
|
|
1472
|
+
const hasMore = limit > 0 ? offset + limit < total : false;
|
|
1473
|
+
|
|
1474
|
+
return {
|
|
1475
|
+
sessions: paginatedSessions,
|
|
1476
|
+
hasMore,
|
|
1477
|
+
total,
|
|
1478
|
+
offset,
|
|
1479
|
+
limit
|
|
1480
|
+
};
|
|
1481
|
+
} catch (error) {
|
|
1482
|
+
console.error(`Error reading sessions for project ${projectName}:`, error);
|
|
1483
|
+
return { sessions: [], hasMore: false, total: 0 };
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
async function parseJsonlSessions(filePath, projectName = null, dbSessionMap = null) {
|
|
1488
|
+
const sessions = new Map();
|
|
1489
|
+
const entries = [];
|
|
1490
|
+
const pendingSummaries = new Map(); // leafUuid -> summary for entries without sessionId
|
|
1491
|
+
|
|
1492
|
+
try {
|
|
1493
|
+
const fileStream = fsSync.createReadStream(filePath);
|
|
1494
|
+
const rl = readline.createInterface({
|
|
1495
|
+
input: fileStream,
|
|
1496
|
+
crlfDelay: Infinity
|
|
1497
|
+
});
|
|
1498
|
+
|
|
1499
|
+
for await (const line of rl) {
|
|
1500
|
+
if (line.trim()) {
|
|
1501
|
+
try {
|
|
1502
|
+
const entry = JSON.parse(line);
|
|
1503
|
+
entries.push(entry);
|
|
1504
|
+
|
|
1505
|
+
// Handle summary entries that don't have sessionId yet
|
|
1506
|
+
if (entry.type === 'summary' && entry.summary && !entry.sessionId && entry.leafUuid) {
|
|
1507
|
+
pendingSummaries.set(entry.leafUuid, entry.summary);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
if (entry.sessionId) {
|
|
1511
|
+
if (!sessions.has(entry.sessionId)) {
|
|
1512
|
+
// Priority: 1. DB name, 2. Default
|
|
1513
|
+
let initialSummary = 'New Session';
|
|
1514
|
+
if (dbSessionMap && dbSessionMap.has(entry.sessionId)) {
|
|
1515
|
+
initialSummary = dbSessionMap.get(entry.sessionId).display_name;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
sessions.set(entry.sessionId, {
|
|
1519
|
+
id: entry.sessionId,
|
|
1520
|
+
summary: initialSummary,
|
|
1521
|
+
messageCount: 0,
|
|
1522
|
+
lastActivity: new Date(),
|
|
1523
|
+
cwd: entry.cwd || '',
|
|
1524
|
+
lastUserMessage: null,
|
|
1525
|
+
lastAssistantMessage: null,
|
|
1526
|
+
mode: dbSessionMap && dbSessionMap.has(entry.sessionId)
|
|
1527
|
+
? (readExplicitSessionModeFromMetadata(dbSessionMap.get(entry.sessionId).metadata) || 'research')
|
|
1528
|
+
: 'research',
|
|
1529
|
+
tags: dbSessionMap && dbSessionMap.has(entry.sessionId)
|
|
1530
|
+
? (Array.isArray(dbSessionMap.get(entry.sessionId).tags) ? dbSessionMap.get(entry.sessionId).tags : [])
|
|
1531
|
+
: []
|
|
1532
|
+
});
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
const session = sessions.get(entry.sessionId);
|
|
1536
|
+
|
|
1537
|
+
// If we have a DB name, we might skip the logic to overwrite it with "New Session" file logs,
|
|
1538
|
+
// but we still want to update it if the file has a LATEST summary entry that might be newer.
|
|
1539
|
+
// For now, manual DB renames should take precedence if they are different from 'New Session'
|
|
1540
|
+
|
|
1541
|
+
// Apply pending summary if this entry has a parentUuid that matches a pending summary
|
|
1542
|
+
if (session.summary === 'New Session' && entry.parentUuid && pendingSummaries.has(entry.parentUuid)) {
|
|
1543
|
+
session.summary = pendingSummaries.get(entry.parentUuid);
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// Update summary from summary entries with sessionId - always take the LATEST in the file
|
|
1547
|
+
if (entry.type === 'summary' && entry.summary) {
|
|
1548
|
+
session.summary = stripInternalContextPrefix(entry.summary);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// Track last user and assistant messages (skip system messages)
|
|
1552
|
+
if (entry.message?.role === 'user' && entry.message?.content) {
|
|
1553
|
+
const content = entry.message.content;
|
|
1554
|
+
|
|
1555
|
+
// Extract text from all text parts if it's an array
|
|
1556
|
+
let textContent = '';
|
|
1557
|
+
if (Array.isArray(content)) {
|
|
1558
|
+
textContent = content
|
|
1559
|
+
.filter(part => part.type === 'text')
|
|
1560
|
+
.map(part => part.text)
|
|
1561
|
+
.join(' ');
|
|
1562
|
+
} else if (typeof content === 'string') {
|
|
1563
|
+
textContent = content;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
const isSystemMessage = typeof textContent === 'string' && (
|
|
1567
|
+
textContent.startsWith('<command-name>') ||
|
|
1568
|
+
textContent.startsWith('<command-message>') ||
|
|
1569
|
+
textContent.startsWith('<command-args>') ||
|
|
1570
|
+
textContent.startsWith('<local-command-stdout>') ||
|
|
1571
|
+
textContent.startsWith('<system-reminder>') ||
|
|
1572
|
+
textContent.startsWith('Caveat:') ||
|
|
1573
|
+
textContent.startsWith('This session is being continued from a previous') ||
|
|
1574
|
+
textContent.startsWith('Invalid API key') ||
|
|
1575
|
+
textContent.includes('{"subtasks":') || // Filter Task Master prompts
|
|
1576
|
+
textContent.includes('CRITICAL: You MUST respond with ONLY a JSON') || // Filter Task Master system prompts
|
|
1577
|
+
textContent === 'Warmup' // Explicitly filter out "Warmup"
|
|
1578
|
+
);
|
|
1579
|
+
|
|
1580
|
+
const modeFromMessage = typeof textContent === 'string'
|
|
1581
|
+
? extractSessionModeFromText(textContent)
|
|
1582
|
+
: null;
|
|
1583
|
+
if (modeFromMessage) {
|
|
1584
|
+
session.mode = modeFromMessage;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
if (textContent && textContent.length > 0) {
|
|
1588
|
+
const cleaned = stripInternalContextPrefix(textContent, false);
|
|
1589
|
+
|
|
1590
|
+
const isSystemMessage = typeof cleaned === 'string' && (
|
|
1591
|
+
cleaned.startsWith('<command-name>') ||
|
|
1592
|
+
cleaned.startsWith('<command-message>') ||
|
|
1593
|
+
cleaned.startsWith('<command-args>') ||
|
|
1594
|
+
cleaned.startsWith('<local-command-stdout>') ||
|
|
1595
|
+
cleaned.startsWith('<system-reminder>') ||
|
|
1596
|
+
cleaned.startsWith('Caveat:') ||
|
|
1597
|
+
cleaned.startsWith('This session is being continued from a previous') ||
|
|
1598
|
+
cleaned.startsWith('Invalid API key') ||
|
|
1599
|
+
cleaned.includes('{"subtasks":') || // Filter Task Master prompts
|
|
1600
|
+
cleaned.includes('CRITICAL: You MUST respond with ONLY a JSON') || // Filter Task Master system prompts
|
|
1601
|
+
cleaned === 'Warmup' // Explicitly filter out "Warmup"
|
|
1602
|
+
);
|
|
1603
|
+
|
|
1604
|
+
if (cleaned && !isSystemMessage) {
|
|
1605
|
+
// If this is the very first message (no parent), use it as initial summary
|
|
1606
|
+
if (entry.parentUuid === null && session.summary === 'New Session') {
|
|
1607
|
+
session.summary = cleaned.length > 50 ? cleaned.substring(0, 50) + '...' : cleaned;
|
|
1608
|
+
}
|
|
1609
|
+
session.lastUserMessage = cleaned;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
} else if (entry.message?.role === 'assistant' && entry.message?.content) {
|
|
1613
|
+
// Skip API error messages using the isApiErrorMessage flag
|
|
1614
|
+
if (entry.isApiErrorMessage === true) {
|
|
1615
|
+
// Skip this message entirely
|
|
1616
|
+
} else {
|
|
1617
|
+
// Track last assistant text message
|
|
1618
|
+
let assistantText = null;
|
|
1619
|
+
|
|
1620
|
+
if (Array.isArray(entry.message.content)) {
|
|
1621
|
+
for (const part of entry.message.content) {
|
|
1622
|
+
if (part.type === 'text' && part.text) {
|
|
1623
|
+
assistantText = part.text;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
} else if (typeof entry.message.content === 'string') {
|
|
1627
|
+
assistantText = entry.message.content;
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
if (assistantText) {
|
|
1631
|
+
const cleaned = stripInternalContextPrefix(assistantText, false);
|
|
1632
|
+
|
|
1633
|
+
// Additional filter for assistant messages with system content
|
|
1634
|
+
const isSystemAssistantMessage = typeof cleaned === 'string' && (
|
|
1635
|
+
cleaned.startsWith('Invalid API key') ||
|
|
1636
|
+
cleaned.includes('{"subtasks":') ||
|
|
1637
|
+
cleaned.includes('CRITICAL: You MUST respond with ONLY a JSON')
|
|
1638
|
+
);
|
|
1639
|
+
|
|
1640
|
+
if (cleaned && !isSystemAssistantMessage) {
|
|
1641
|
+
session.lastAssistantMessage = cleaned;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
session.messageCount++;
|
|
1648
|
+
|
|
1649
|
+
if (entry.timestamp) {
|
|
1650
|
+
session.lastActivity = new Date(entry.timestamp);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
} catch (parseError) {
|
|
1654
|
+
// Skip malformed lines silently
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
// After processing all entries, set final summary based on last message if no summary exists
|
|
1660
|
+
for (const session of sessions.values()) {
|
|
1661
|
+
if (session.summary === 'New Session') {
|
|
1662
|
+
// Prefer last user message, fall back to last assistant message
|
|
1663
|
+
const lastMessage = session.lastUserMessage || session.lastAssistantMessage;
|
|
1664
|
+
if (lastMessage) {
|
|
1665
|
+
session.summary = lastMessage.length > 50 ? lastMessage.substring(0, 50) + '...' : lastMessage;
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// Filter out sessions that contain JSON responses (Task Master errors)
|
|
1671
|
+
const allSessions = Array.from(sessions.values());
|
|
1672
|
+
const filteredSessions = allSessions.filter(session => {
|
|
1673
|
+
const shouldFilter = session.summary.startsWith('{ "');
|
|
1674
|
+
if (shouldFilter) {
|
|
1675
|
+
}
|
|
1676
|
+
// Log a sample of summaries to debug
|
|
1677
|
+
if (Math.random() < 0.01) { // Log 1% of sessions
|
|
1678
|
+
}
|
|
1679
|
+
return !shouldFilter;
|
|
1680
|
+
});
|
|
1681
|
+
|
|
1682
|
+
|
|
1683
|
+
return {
|
|
1684
|
+
sessions: filteredSessions,
|
|
1685
|
+
entries: entries
|
|
1686
|
+
};
|
|
1687
|
+
|
|
1688
|
+
} catch (error) {
|
|
1689
|
+
console.error('Error reading JSONL file:', error);
|
|
1690
|
+
return { sessions: [], entries: [] };
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// Parse an agent JSONL file and extract tool uses/results for grouped rendering
|
|
1695
|
+
async function parseAgentTools(filePath) {
|
|
1696
|
+
const tools = [];
|
|
1697
|
+
|
|
1698
|
+
try {
|
|
1699
|
+
const fileStream = fsSync.createReadStream(filePath);
|
|
1700
|
+
const rl = readline.createInterface({
|
|
1701
|
+
input: fileStream,
|
|
1702
|
+
crlfDelay: Infinity
|
|
1703
|
+
});
|
|
1704
|
+
|
|
1705
|
+
for await (const line of rl) {
|
|
1706
|
+
if (!line.trim()) continue;
|
|
1707
|
+
|
|
1708
|
+
try {
|
|
1709
|
+
const entry = JSON.parse(line);
|
|
1710
|
+
|
|
1711
|
+
if (entry.message?.role === 'assistant' && Array.isArray(entry.message?.content)) {
|
|
1712
|
+
for (const part of entry.message.content) {
|
|
1713
|
+
if (part.type === 'tool_use') {
|
|
1714
|
+
tools.push({
|
|
1715
|
+
toolId: part.id,
|
|
1716
|
+
toolName: part.name,
|
|
1717
|
+
toolInput: part.input,
|
|
1718
|
+
timestamp: entry.timestamp
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
if (entry.message?.role === 'user' && Array.isArray(entry.message?.content)) {
|
|
1725
|
+
for (const part of entry.message.content) {
|
|
1726
|
+
if (part.type === 'tool_result') {
|
|
1727
|
+
const tool = tools.find(t => t.toolId === part.tool_use_id);
|
|
1728
|
+
if (tool) {
|
|
1729
|
+
tool.toolResult = {
|
|
1730
|
+
content: typeof part.content === 'string'
|
|
1731
|
+
? part.content
|
|
1732
|
+
: Array.isArray(part.content)
|
|
1733
|
+
? part.content.map(c => c.text || '').join('\n')
|
|
1734
|
+
: JSON.stringify(part.content),
|
|
1735
|
+
isError: Boolean(part.is_error)
|
|
1736
|
+
};
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
} catch (parseError) {
|
|
1742
|
+
// Skip malformed lines
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1745
|
+
} catch (error) {
|
|
1746
|
+
console.warn(`Error parsing agent file ${filePath}:`, error.message);
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
return tools;
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
// Get messages for a specific session with pagination support
|
|
1753
|
+
async function getSessionMessages(projectName, sessionId, limit = null, offset = 0, provider = 'claude', userId = null) {
|
|
1754
|
+
console.log(`[DEBUG] getSessionMessages - project: ${projectName}, session: ${sessionId}, provider: ${provider}`);
|
|
1755
|
+
if (provider === 'gemini') {
|
|
1756
|
+
const geminiSessionFile = path.join(os.homedir(), '.gemini', 'sessions', `${sessionId}.jsonl`);
|
|
1757
|
+
console.log(`[DEBUG] Reading Gemini session file: ${geminiSessionFile}`);
|
|
1758
|
+
try {
|
|
1759
|
+
await fs.access(geminiSessionFile);
|
|
1760
|
+
const messages = [];
|
|
1761
|
+
const mergeGeminiMessageFragment = (entry) => {
|
|
1762
|
+
const role = entry.role || entry.message?.role;
|
|
1763
|
+
const content = entry.content || entry.message?.content;
|
|
1764
|
+
const isMessageEntry = entry.type === 'message' || (role && content);
|
|
1765
|
+
const isStringContent = typeof content === 'string';
|
|
1766
|
+
|
|
1767
|
+
if (!isMessageEntry || !isStringContent || !role) {
|
|
1768
|
+
messages.push(entry);
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
const last = messages[messages.length - 1];
|
|
1773
|
+
const lastRole = last?.role || last?.message?.role;
|
|
1774
|
+
const lastContent = last?.content || last?.message?.content;
|
|
1775
|
+
const canMerge =
|
|
1776
|
+
last &&
|
|
1777
|
+
last.type === 'message' &&
|
|
1778
|
+
lastRole === role &&
|
|
1779
|
+
typeof lastContent === 'string';
|
|
1780
|
+
|
|
1781
|
+
if (canMerge && role === 'assistant') {
|
|
1782
|
+
last.content = `${lastContent}${content}`;
|
|
1783
|
+
if (entry.timestamp) {
|
|
1784
|
+
last.timestamp = entry.timestamp;
|
|
1785
|
+
}
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
if (canMerge && role === 'user' && lastContent === content) {
|
|
1790
|
+
if (entry.timestamp) {
|
|
1791
|
+
last.timestamp = entry.timestamp;
|
|
1792
|
+
}
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
messages.push(entry);
|
|
1797
|
+
};
|
|
1798
|
+
const fileStream = fsSync.createReadStream(geminiSessionFile);
|
|
1799
|
+
const rl = readline.createInterface({
|
|
1800
|
+
input: fileStream,
|
|
1801
|
+
crlfDelay: Infinity
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
for await (const line of rl) {
|
|
1805
|
+
if (line.trim()) {
|
|
1806
|
+
try {
|
|
1807
|
+
const entry = JSON.parse(line);
|
|
1808
|
+
// Gemini JSONL files store messages with 'role' or 'type'
|
|
1809
|
+
const role = entry.role || entry.message?.role;
|
|
1810
|
+
const content = entry.content || entry.message?.content;
|
|
1811
|
+
const hasContent = content || (Array.isArray(entry.message?.content) || typeof entry.message?.content === 'string');
|
|
1812
|
+
|
|
1813
|
+
if (entry.type === 'message' || (role && hasContent) || entry.type === 'tool_use' || entry.type === 'tool_result') {
|
|
1814
|
+
// Ensure role and content are available at top level for the frontend
|
|
1815
|
+
if (!entry.role && role) entry.role = role;
|
|
1816
|
+
if (!entry.content && content) entry.content = content;
|
|
1817
|
+
mergeGeminiMessageFragment(entry);
|
|
1818
|
+
}
|
|
1819
|
+
} catch (parseError) {}
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
console.log(`[DEBUG] Found ${messages.length} valid messages in Gemini session file`);
|
|
1824
|
+
const total = messages.length;
|
|
1825
|
+
if (limit === null) return messages;
|
|
1826
|
+
|
|
1827
|
+
const startIndex = Math.max(0, total - offset - limit);
|
|
1828
|
+
const endIndex = total - offset;
|
|
1829
|
+
const paginatedMessages = messages.slice(startIndex, endIndex);
|
|
1830
|
+
|
|
1831
|
+
return {
|
|
1832
|
+
messages: paginatedMessages,
|
|
1833
|
+
total,
|
|
1834
|
+
hasMore: startIndex > 0,
|
|
1835
|
+
offset,
|
|
1836
|
+
limit
|
|
1837
|
+
};
|
|
1838
|
+
} catch (e) {
|
|
1839
|
+
console.warn(`Could not read Gemini session ${sessionId}:`, e.message);
|
|
1840
|
+
return limit === null ? [] : { messages: [], total: 0, hasMore: false };
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
if (provider === 'openrouter') {
|
|
1845
|
+
const openrouterSessionFile = path.join(os.homedir(), '.dr-claw', 'openrouter-sessions', `${sessionId}.jsonl`);
|
|
1846
|
+
console.log(`[DEBUG] Reading OpenRouter session file: ${openrouterSessionFile}`);
|
|
1847
|
+
try {
|
|
1848
|
+
await fs.access(openrouterSessionFile);
|
|
1849
|
+
const messages = [];
|
|
1850
|
+
const raw = await fs.readFile(openrouterSessionFile, 'utf-8');
|
|
1851
|
+
for (const line of raw.trim().split('\n').filter(Boolean)) {
|
|
1852
|
+
try {
|
|
1853
|
+
const entry = JSON.parse(line);
|
|
1854
|
+
if (entry.role === 'system') continue;
|
|
1855
|
+
if (entry.role === 'user') {
|
|
1856
|
+
messages.push({
|
|
1857
|
+
type: 'message',
|
|
1858
|
+
role: 'user',
|
|
1859
|
+
content: entry.content || '',
|
|
1860
|
+
timestamp: entry.ts,
|
|
1861
|
+
});
|
|
1862
|
+
} else if (entry.role === 'assistant') {
|
|
1863
|
+
// Emit text content as a message if present
|
|
1864
|
+
if (entry.content) {
|
|
1865
|
+
messages.push({
|
|
1866
|
+
type: 'message',
|
|
1867
|
+
role: 'assistant',
|
|
1868
|
+
content: entry.content,
|
|
1869
|
+
timestamp: entry.ts,
|
|
1870
|
+
});
|
|
1871
|
+
}
|
|
1872
|
+
// Emit tool_use entries for each tool call (matches Codex/Claude history format)
|
|
1873
|
+
if (Array.isArray(entry.tool_calls)) {
|
|
1874
|
+
for (const tc of entry.tool_calls) {
|
|
1875
|
+
let toolInput;
|
|
1876
|
+
try { toolInput = tc.function?.arguments || '{}'; } catch { toolInput = '{}'; }
|
|
1877
|
+
messages.push({
|
|
1878
|
+
type: 'tool_use',
|
|
1879
|
+
timestamp: entry.ts,
|
|
1880
|
+
toolName: tc.function?.name || 'unknown',
|
|
1881
|
+
toolInput,
|
|
1882
|
+
toolCallId: tc.id,
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
} else if (entry.role === 'tool') {
|
|
1887
|
+
messages.push({
|
|
1888
|
+
type: 'tool_result',
|
|
1889
|
+
role: 'tool',
|
|
1890
|
+
output: entry.content,
|
|
1891
|
+
tool_call_id: entry.tool_call_id,
|
|
1892
|
+
toolCallId: entry.tool_call_id,
|
|
1893
|
+
timestamp: entry.ts,
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
} catch {}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
console.log(`[DEBUG] Found ${messages.length} valid messages in OpenRouter session file`);
|
|
1900
|
+
const total = messages.length;
|
|
1901
|
+
if (limit === null) return messages;
|
|
1902
|
+
|
|
1903
|
+
const startIndex = Math.max(0, total - offset - limit);
|
|
1904
|
+
const endIndex = total - offset;
|
|
1905
|
+
return {
|
|
1906
|
+
messages: messages.slice(startIndex, endIndex),
|
|
1907
|
+
total,
|
|
1908
|
+
hasMore: startIndex > 0,
|
|
1909
|
+
offset,
|
|
1910
|
+
limit,
|
|
1911
|
+
};
|
|
1912
|
+
} catch (e) {
|
|
1913
|
+
console.warn(`Could not read OpenRouter session ${sessionId}:`, e.message);
|
|
1914
|
+
return limit === null ? [] : { messages: [], total: 0, hasMore: false };
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
if (provider === 'local') {
|
|
1919
|
+
const localSessionFile = path.join(os.homedir(), '.dr-claw', 'localgpu-sessions', `${sessionId}.jsonl`);
|
|
1920
|
+
console.log(`[DEBUG] Reading Local GPU session file: ${localSessionFile}`);
|
|
1921
|
+
try {
|
|
1922
|
+
await fs.access(localSessionFile);
|
|
1923
|
+
const messages = [];
|
|
1924
|
+
const raw = await fs.readFile(localSessionFile, 'utf-8');
|
|
1925
|
+
for (const line of raw.trim().split('\n').filter(Boolean)) {
|
|
1926
|
+
try {
|
|
1927
|
+
const entry = JSON.parse(line);
|
|
1928
|
+
if (entry.role === 'system') continue;
|
|
1929
|
+
if (entry.role === 'user') {
|
|
1930
|
+
messages.push({
|
|
1931
|
+
type: 'message',
|
|
1932
|
+
role: 'user',
|
|
1933
|
+
content: entry.content || '',
|
|
1934
|
+
timestamp: entry.ts,
|
|
1935
|
+
});
|
|
1936
|
+
} else if (entry.role === 'assistant') {
|
|
1937
|
+
if (entry.content) {
|
|
1938
|
+
messages.push({
|
|
1939
|
+
type: 'message',
|
|
1940
|
+
role: 'assistant',
|
|
1941
|
+
content: entry.content,
|
|
1942
|
+
timestamp: entry.ts,
|
|
1943
|
+
});
|
|
1944
|
+
}
|
|
1945
|
+
if (Array.isArray(entry.tool_calls)) {
|
|
1946
|
+
for (const tc of entry.tool_calls) {
|
|
1947
|
+
let toolInput;
|
|
1948
|
+
try { toolInput = tc.function?.arguments || '{}'; } catch { toolInput = '{}'; }
|
|
1949
|
+
messages.push({
|
|
1950
|
+
type: 'tool_use',
|
|
1951
|
+
timestamp: entry.ts,
|
|
1952
|
+
toolName: tc.function?.name || 'unknown',
|
|
1953
|
+
toolInput,
|
|
1954
|
+
toolCallId: tc.id,
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
} else if (entry.role === 'tool') {
|
|
1959
|
+
messages.push({
|
|
1960
|
+
type: 'tool_result',
|
|
1961
|
+
role: 'tool',
|
|
1962
|
+
output: entry.content,
|
|
1963
|
+
tool_call_id: entry.tool_call_id,
|
|
1964
|
+
toolCallId: entry.tool_call_id,
|
|
1965
|
+
timestamp: entry.ts,
|
|
1966
|
+
});
|
|
1967
|
+
}
|
|
1968
|
+
} catch {}
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
console.log(`[DEBUG] Found ${messages.length} valid messages in Local GPU session file`);
|
|
1972
|
+
const total = messages.length;
|
|
1973
|
+
if (limit === null) return messages;
|
|
1974
|
+
|
|
1975
|
+
const startIndex = Math.max(0, total - offset - limit);
|
|
1976
|
+
const endIndex = total - offset;
|
|
1977
|
+
return {
|
|
1978
|
+
messages: messages.slice(startIndex, endIndex),
|
|
1979
|
+
total,
|
|
1980
|
+
hasMore: startIndex > 0,
|
|
1981
|
+
offset,
|
|
1982
|
+
limit,
|
|
1983
|
+
};
|
|
1984
|
+
} catch (e) {
|
|
1985
|
+
console.warn(`Could not read Local GPU session ${sessionId}:`, e.message);
|
|
1986
|
+
return limit === null ? [] : { messages: [], total: 0, hasMore: false };
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
|
1991
|
+
|
|
1992
|
+
try {
|
|
1993
|
+
const files = await fs.readdir(projectDir);
|
|
1994
|
+
// agent-*.jsonl files contain subagent tool history, handled separately below
|
|
1995
|
+
const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
|
|
1996
|
+
const agentFiles = files.filter(file => file.endsWith('.jsonl') && file.startsWith('agent-'));
|
|
1997
|
+
|
|
1998
|
+
if (jsonlFiles.length === 0) {
|
|
1999
|
+
return { messages: [], total: 0, hasMore: false };
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
const messages = [];
|
|
2003
|
+
const agentToolsCache = new Map();
|
|
2004
|
+
|
|
2005
|
+
// Process all JSONL files to find messages for this session
|
|
2006
|
+
for (const file of jsonlFiles) {
|
|
2007
|
+
const jsonlFile = path.join(projectDir, file);
|
|
2008
|
+
const fileStream = fsSync.createReadStream(jsonlFile);
|
|
2009
|
+
const rl = readline.createInterface({
|
|
2010
|
+
input: fileStream,
|
|
2011
|
+
crlfDelay: Infinity
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
for await (const line of rl) {
|
|
2015
|
+
if (line.trim()) {
|
|
2016
|
+
try {
|
|
2017
|
+
const entry = JSON.parse(line);
|
|
2018
|
+
if (entry.sessionId === sessionId) {
|
|
2019
|
+
messages.push(entry);
|
|
2020
|
+
}
|
|
2021
|
+
} catch (parseError) {
|
|
2022
|
+
console.warn('Error parsing line:', parseError.message);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
// Collect Task agent IDs and hydrate grouped subagent tool history
|
|
2029
|
+
const agentIds = new Set();
|
|
2030
|
+
for (const message of messages) {
|
|
2031
|
+
if (message.toolUseResult?.agentId) {
|
|
2032
|
+
agentIds.add(message.toolUseResult.agentId);
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
for (const agentId of agentIds) {
|
|
2037
|
+
const agentFileName = `agent-${agentId}.jsonl`;
|
|
2038
|
+
if (agentFiles.includes(agentFileName)) {
|
|
2039
|
+
const agentFilePath = path.join(projectDir, agentFileName);
|
|
2040
|
+
const tools = await parseAgentTools(agentFilePath);
|
|
2041
|
+
agentToolsCache.set(agentId, tools);
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
for (const message of messages) {
|
|
2046
|
+
if (message.toolUseResult?.agentId) {
|
|
2047
|
+
const tools = agentToolsCache.get(message.toolUseResult.agentId);
|
|
2048
|
+
if (tools && tools.length > 0) {
|
|
2049
|
+
message.subagentTools = tools;
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
// Sort messages by timestamp
|
|
2055
|
+
const sortedMessages = messages.sort((a, b) =>
|
|
2056
|
+
new Date(a.timestamp || 0) - new Date(b.timestamp || 0)
|
|
2057
|
+
);
|
|
2058
|
+
|
|
2059
|
+
const total = sortedMessages.length;
|
|
2060
|
+
|
|
2061
|
+
// If no limit is specified, return all messages (backward compatibility)
|
|
2062
|
+
if (limit === null) {
|
|
2063
|
+
return sortedMessages;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
// Apply pagination - for recent messages, we need to slice from the end
|
|
2067
|
+
// offset 0 should give us the most recent messages
|
|
2068
|
+
const startIndex = Math.max(0, total - offset - limit);
|
|
2069
|
+
const endIndex = total - offset;
|
|
2070
|
+
const paginatedMessages = sortedMessages.slice(startIndex, endIndex);
|
|
2071
|
+
const hasMore = startIndex > 0;
|
|
2072
|
+
|
|
2073
|
+
return {
|
|
2074
|
+
messages: paginatedMessages,
|
|
2075
|
+
total,
|
|
2076
|
+
hasMore,
|
|
2077
|
+
offset,
|
|
2078
|
+
limit
|
|
2079
|
+
};
|
|
2080
|
+
} catch (error) {
|
|
2081
|
+
console.error(`Error reading messages for session ${sessionId}:`, error);
|
|
2082
|
+
return limit === null ? [] : { messages: [], total: 0, hasMore: false };
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// Rename a project's display name
|
|
2087
|
+
async function renameProject(projectName, newDisplayName, userId = null) {
|
|
2088
|
+
const { projectDb } = await import('./database/db.js');
|
|
2089
|
+
const trimmedName = (newDisplayName || '').trim();
|
|
2090
|
+
|
|
2091
|
+
const existing = projectDb.getProjectById(projectName);
|
|
2092
|
+
if (existing) {
|
|
2093
|
+
if (userId && existing.user_id && existing.user_id !== userId) {
|
|
2094
|
+
throw new Error('You do not have permission to rename this project');
|
|
2095
|
+
}
|
|
2096
|
+
projectDb.updateProjectName(projectName, trimmedName);
|
|
2097
|
+
} else {
|
|
2098
|
+
const actualPath = await extractProjectDirectory(projectName);
|
|
2099
|
+
projectDb.upsertProject(projectName, userId, trimmedName, actualPath);
|
|
2100
|
+
}
|
|
2101
|
+
|
|
2102
|
+
await mutateProjectConfig(async (config) => {
|
|
2103
|
+
if (!trimmedName) {
|
|
2104
|
+
if (config[projectName]) {
|
|
2105
|
+
delete config[projectName].displayName;
|
|
2106
|
+
if (Object.keys(config[projectName]).length === 0) {
|
|
2107
|
+
delete config[projectName];
|
|
2108
|
+
}
|
|
2109
|
+
}
|
|
2110
|
+
return;
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
if (!config[projectName]) {
|
|
2114
|
+
const actualPath = await extractProjectDirectory(projectName);
|
|
2115
|
+
config[projectName] = {
|
|
2116
|
+
originalPath: actualPath
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
|
|
2120
|
+
config[projectName].displayName = trimmedName;
|
|
2121
|
+
});
|
|
2122
|
+
|
|
2123
|
+
return true;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
// Delete a session from a project
|
|
2127
|
+
async function deleteSession(projectName, sessionId, provider = 'claude') {
|
|
2128
|
+
const { sessionDb } = await import('./database/db.js');
|
|
2129
|
+
const indexedSession = sessionDb.getSessionById(sessionId);
|
|
2130
|
+
|
|
2131
|
+
if (provider === 'gemini') {
|
|
2132
|
+
const geminiSessionFile = path.join(os.homedir(), '.gemini', 'sessions', `${sessionId}.jsonl`);
|
|
2133
|
+
let deletedFile = false;
|
|
2134
|
+
try {
|
|
2135
|
+
await fs.unlink(geminiSessionFile);
|
|
2136
|
+
deletedFile = true;
|
|
2137
|
+
} catch (e) {
|
|
2138
|
+
if (e?.code !== 'ENOENT') {
|
|
2139
|
+
console.error(`[Gemini] Failed to delete session ${sessionId}:`, e.message);
|
|
2140
|
+
throw new Error(`Failed to delete Gemini session: ${e.message}`);
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
const deletedIndex = indexedSession?.provider === 'gemini' || deletedFile;
|
|
2145
|
+
if (deletedIndex) {
|
|
2146
|
+
sessionDb.deleteSession(sessionId);
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
if (deletedFile || deletedIndex) {
|
|
2150
|
+
console.log(`[Gemini] Deleted session ${sessionId}${deletedFile ? ` file: ${geminiSessionFile}` : ' from index only'}`);
|
|
2151
|
+
return true;
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
throw new Error(`Gemini session ${sessionId} not found in file system or index`);
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
if (provider === 'openrouter') {
|
|
2158
|
+
const openrouterSessionFile = path.join(os.homedir(), '.dr-claw', 'openrouter-sessions', `${sessionId}.jsonl`);
|
|
2159
|
+
let deletedFile = false;
|
|
2160
|
+
try {
|
|
2161
|
+
await fs.unlink(openrouterSessionFile);
|
|
2162
|
+
deletedFile = true;
|
|
2163
|
+
} catch (e) {
|
|
2164
|
+
if (e?.code !== 'ENOENT') {
|
|
2165
|
+
console.error(`[OpenRouter] Failed to delete session ${sessionId}:`, e.message);
|
|
2166
|
+
throw new Error(`Failed to delete OpenRouter session: ${e.message}`);
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
const deletedIndex = indexedSession?.provider === 'openrouter' || deletedFile;
|
|
2171
|
+
if (deletedIndex) {
|
|
2172
|
+
sessionDb.deleteSession(sessionId);
|
|
2173
|
+
}
|
|
2174
|
+
|
|
2175
|
+
if (deletedFile || deletedIndex) {
|
|
2176
|
+
console.log(`[OpenRouter] Deleted session ${sessionId}${deletedFile ? ` file: ${openrouterSessionFile}` : ' from index only'}`);
|
|
2177
|
+
return true;
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
throw new Error(`OpenRouter session ${sessionId} not found in file system or index`);
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
if (provider === 'local') {
|
|
2184
|
+
const localSessionFile = path.join(os.homedir(), '.dr-claw', 'localgpu-sessions', `${sessionId}.jsonl`);
|
|
2185
|
+
let deletedFile = false;
|
|
2186
|
+
try {
|
|
2187
|
+
await fs.unlink(localSessionFile);
|
|
2188
|
+
deletedFile = true;
|
|
2189
|
+
} catch (e) {
|
|
2190
|
+
if (e?.code !== 'ENOENT') {
|
|
2191
|
+
console.error(`[LocalGPU] Failed to delete session ${sessionId}:`, e.message);
|
|
2192
|
+
throw new Error(`Failed to delete Local GPU session: ${e.message}`);
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
const deletedIndex = indexedSession?.provider === 'local' || deletedFile;
|
|
2197
|
+
if (deletedIndex) {
|
|
2198
|
+
sessionDb.deleteSession(sessionId);
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
if (deletedFile || deletedIndex) {
|
|
2202
|
+
console.log(`[LocalGPU] Deleted session ${sessionId}${deletedFile ? ` file: ${localSessionFile}` : ' from index only'}`);
|
|
2203
|
+
return true;
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
throw new Error(`Local GPU session ${sessionId} not found in file system or index`);
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
|
2210
|
+
|
|
2211
|
+
try {
|
|
2212
|
+
const files = await fs.readdir(projectDir);
|
|
2213
|
+
const jsonlFiles = files.filter(file => file.endsWith('.jsonl'));
|
|
2214
|
+
|
|
2215
|
+
let matchedFiles = 0;
|
|
2216
|
+
let removedEntries = 0;
|
|
2217
|
+
|
|
2218
|
+
for (const file of jsonlFiles) {
|
|
2219
|
+
const jsonlFile = path.join(projectDir, file);
|
|
2220
|
+
const content = await fs.readFile(jsonlFile, 'utf8');
|
|
2221
|
+
const lines = content.split('\n').filter(line => line.trim());
|
|
2222
|
+
let fileRemovedEntries = 0;
|
|
2223
|
+
|
|
2224
|
+
const filteredLines = lines.filter(line => {
|
|
2225
|
+
try {
|
|
2226
|
+
const data = JSON.parse(line);
|
|
2227
|
+
if (data.sessionId === sessionId) {
|
|
2228
|
+
fileRemovedEntries += 1;
|
|
2229
|
+
return false;
|
|
2230
|
+
}
|
|
2231
|
+
return true;
|
|
2232
|
+
} catch {
|
|
2233
|
+
return true; // Keep malformed lines
|
|
2234
|
+
}
|
|
2235
|
+
});
|
|
2236
|
+
|
|
2237
|
+
if (fileRemovedEntries > 0) {
|
|
2238
|
+
matchedFiles += 1;
|
|
2239
|
+
removedEntries += fileRemovedEntries;
|
|
2240
|
+
|
|
2241
|
+
if (filteredLines.length > 0) {
|
|
2242
|
+
await fs.writeFile(jsonlFile, filteredLines.join('\n') + '\n');
|
|
2243
|
+
} else {
|
|
2244
|
+
await fs.unlink(jsonlFile);
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
const deletedIndex = indexedSession?.provider === 'claude' || matchedFiles > 0;
|
|
2250
|
+
if (deletedIndex) {
|
|
2251
|
+
sessionDb.deleteSession(sessionId);
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
if (matchedFiles > 0 || deletedIndex) {
|
|
2255
|
+
console.log(
|
|
2256
|
+
`[Claude] Deleted session ${sessionId} from ${matchedFiles} file(s), removed ${removedEntries} entr${removedEntries === 1 ? 'y' : 'ies'}`,
|
|
2257
|
+
);
|
|
2258
|
+
return true;
|
|
2259
|
+
}
|
|
2260
|
+
|
|
2261
|
+
throw new Error(`Session ${sessionId} not found in any files or index`);
|
|
2262
|
+
} catch (error) {
|
|
2263
|
+
if (error?.code === 'ENOENT' && indexedSession?.provider === 'claude') {
|
|
2264
|
+
sessionDb.deleteSession(sessionId);
|
|
2265
|
+
console.log(`[Claude] Deleted session ${sessionId} from index only; project directory missing: ${projectDir}`);
|
|
2266
|
+
return true;
|
|
2267
|
+
}
|
|
2268
|
+
console.error(`Error deleting session ${sessionId} from project ${projectName}:`, error);
|
|
2269
|
+
throw error;
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
// Check if a project is empty (has no sessions)
|
|
2274
|
+
async function isProjectEmpty(projectName) {
|
|
2275
|
+
try {
|
|
2276
|
+
const sessionsResult = await getSessions(projectName, 1, 0);
|
|
2277
|
+
return sessionsResult.total === 0;
|
|
2278
|
+
} catch (error) {
|
|
2279
|
+
console.error(`Error checking if project ${projectName} is empty:`, error);
|
|
2280
|
+
return false;
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// Delete a project (force=true to delete with sessions). This hides the project and records it in trash metadata.
|
|
2285
|
+
async function deleteProject(projectName, force = false, userId = null) {
|
|
2286
|
+
const { projectDb, sessionDb } = await import('./database/db.js');
|
|
2287
|
+
|
|
2288
|
+
try {
|
|
2289
|
+
const existing = projectDb.getProjectById(projectName);
|
|
2290
|
+
const initialConfig = await loadProjectConfig();
|
|
2291
|
+
const initialProjectInfo = initialConfig[projectName];
|
|
2292
|
+
const ownerUserId = existing?.user_id ?? getProjectOwnerUserId(initialProjectInfo, existing) ?? userId ?? null;
|
|
2293
|
+
|
|
2294
|
+
if (userId && ownerUserId && ownerUserId !== userId) {
|
|
2295
|
+
throw new Error('You do not have permission to delete this project');
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
const isEmpty = await isProjectEmpty(projectName);
|
|
2299
|
+
if (!isEmpty && !force) {
|
|
2300
|
+
throw new Error('Cannot delete project with existing sessions');
|
|
2301
|
+
}
|
|
2302
|
+
|
|
2303
|
+
if (isProjectTrashed(initialProjectInfo, existing)) {
|
|
2304
|
+
return true;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
const sessionCount = sessionDb.getSessionsByProject(projectName).length;
|
|
2308
|
+
let projectPath = initialProjectInfo?.path || initialProjectInfo?.originalPath || existing?.path || null;
|
|
2309
|
+
if (!projectPath) {
|
|
2310
|
+
projectPath = await extractProjectDirectory(projectName);
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
const trashedAt = new Date().toISOString();
|
|
2314
|
+
const filesExist = await pathExists(projectPath);
|
|
2315
|
+
const instanceId = await readProjectInstanceId(projectPath);
|
|
2316
|
+
const displayName = existing?.display_name || initialProjectInfo?.displayName || path.basename(projectPath || projectName);
|
|
2317
|
+
|
|
2318
|
+
const mutationResult = await mutateProjectConfig((config) => {
|
|
2319
|
+
const currentConfig = config[projectName] || {};
|
|
2320
|
+
if (isProjectTrashed(currentConfig, existing)) {
|
|
2321
|
+
return {
|
|
2322
|
+
alreadyTrashed: true,
|
|
2323
|
+
currentConfig,
|
|
2324
|
+
trashMetadata: currentConfig.trash || existing?.metadata?.trash || null,
|
|
2325
|
+
};
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
clearDeletedProjectMetadata(config, projectName);
|
|
2329
|
+
const trashMetadata = {
|
|
2330
|
+
...(currentConfig.trash || {}),
|
|
2331
|
+
trashedAt,
|
|
2332
|
+
originalPath: projectPath,
|
|
2333
|
+
trashPath: '',
|
|
2334
|
+
claudeTrashPath: '',
|
|
2335
|
+
sessionCount,
|
|
2336
|
+
displayName,
|
|
2337
|
+
filesExist,
|
|
2338
|
+
ownerUserId,
|
|
2339
|
+
instanceId,
|
|
2340
|
+
};
|
|
2341
|
+
|
|
2342
|
+
config[projectName] = {
|
|
2343
|
+
...currentConfig,
|
|
2344
|
+
originalPath: currentConfig.originalPath || projectPath,
|
|
2345
|
+
ownerUserId,
|
|
2346
|
+
trash: trashMetadata,
|
|
2347
|
+
};
|
|
2348
|
+
delete config[projectName].deleted;
|
|
2349
|
+
|
|
2350
|
+
return {
|
|
2351
|
+
alreadyTrashed: false,
|
|
2352
|
+
currentConfig: config[projectName],
|
|
2353
|
+
trashMetadata,
|
|
2354
|
+
};
|
|
2355
|
+
});
|
|
2356
|
+
|
|
2357
|
+
if (mutationResult.alreadyTrashed) {
|
|
2358
|
+
return true;
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
const metadata = {
|
|
2362
|
+
...(existing?.metadata || {}),
|
|
2363
|
+
trash: mutationResult.trashMetadata,
|
|
2364
|
+
};
|
|
2365
|
+
|
|
2366
|
+
if (mutationResult.currentConfig?.manuallyAdded || existing?.metadata?.manuallyAdded) {
|
|
2367
|
+
metadata.manuallyAdded = true;
|
|
2368
|
+
} else {
|
|
2369
|
+
delete metadata.manuallyAdded;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
projectDb.upsertProject(
|
|
2373
|
+
projectName,
|
|
2374
|
+
ownerUserId,
|
|
2375
|
+
existing?.display_name || initialProjectInfo?.displayName || null,
|
|
2376
|
+
projectPath,
|
|
2377
|
+
existing?.is_starred || 0,
|
|
2378
|
+
existing?.last_accessed || null,
|
|
2379
|
+
Object.keys(metadata).length > 0 ? metadata : null,
|
|
2380
|
+
);
|
|
2381
|
+
projectDirectoryCache.delete(projectName);
|
|
2382
|
+
|
|
2383
|
+
return true;
|
|
2384
|
+
} catch (error) {
|
|
2385
|
+
console.error(`Error deleting project ${projectName}:`, error);
|
|
2386
|
+
throw error;
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
async function restoreProject(projectName, userId = null) {
|
|
2391
|
+
const { projectDb } = await import('./database/db.js');
|
|
2392
|
+
const config = await loadProjectConfig();
|
|
2393
|
+
const existing = projectDb.getProjectById(projectName);
|
|
2394
|
+
const projectInfo = config[projectName];
|
|
2395
|
+
const ownerUserId = existing?.user_id ?? getProjectOwnerUserId(projectInfo, existing) ?? userId ?? null;
|
|
2396
|
+
|
|
2397
|
+
if (userId && ownerUserId && ownerUserId !== userId) {
|
|
2398
|
+
throw new Error('You do not have permission to restore this project');
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2401
|
+
const trashMeta = existing?.metadata?.trash || projectInfo?.trash;
|
|
2402
|
+
if (!trashMeta?.trashedAt) {
|
|
2403
|
+
throw new Error('Project is not in trash');
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
const originalPath = trashMeta.originalPath;
|
|
2407
|
+
if (!originalPath) {
|
|
2408
|
+
throw new Error('Original project path is missing');
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
if (!await pathExists(originalPath)) {
|
|
2412
|
+
throw new Error('Project files are missing from the original path and cannot be restored');
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
const nextMetadata = { ...(existing?.metadata || {}) };
|
|
2416
|
+
delete nextMetadata.trash;
|
|
2417
|
+
|
|
2418
|
+
projectDb.upsertProject(
|
|
2419
|
+
projectName,
|
|
2420
|
+
ownerUserId,
|
|
2421
|
+
existing?.display_name || projectInfo?.displayName || trashMeta.displayName || null,
|
|
2422
|
+
originalPath,
|
|
2423
|
+
existing?.is_starred || 0,
|
|
2424
|
+
existing?.last_accessed || null,
|
|
2425
|
+
Object.keys(nextMetadata).length > 0 ? nextMetadata : null,
|
|
2426
|
+
);
|
|
2427
|
+
|
|
2428
|
+
await mutateProjectConfig((nextConfig) => {
|
|
2429
|
+
const nextProjectInfo = {
|
|
2430
|
+
...(nextConfig[projectName] || {}),
|
|
2431
|
+
originalPath,
|
|
2432
|
+
ownerUserId,
|
|
2433
|
+
};
|
|
2434
|
+
delete nextProjectInfo.trash;
|
|
2435
|
+
delete nextProjectInfo.deleted;
|
|
2436
|
+
clearDeletedProjectMetadata(nextConfig, projectName);
|
|
2437
|
+
nextConfig[projectName] = nextProjectInfo;
|
|
2438
|
+
});
|
|
2439
|
+
|
|
2440
|
+
await ensureProjectSkillLinks(originalPath);
|
|
2441
|
+
projectDirectoryCache.delete(projectName);
|
|
2442
|
+
return true;
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
async function deleteTrashedProject(projectName, mode = 'logical', userId = null) {
|
|
2446
|
+
const { projectDb, sessionDb } = await import('./database/db.js');
|
|
2447
|
+
const config = await loadProjectConfig();
|
|
2448
|
+
const existing = projectDb.getProjectById(projectName);
|
|
2449
|
+
const projectInfo = config[projectName];
|
|
2450
|
+
const ownerUserId = existing?.user_id ?? getProjectOwnerUserId(projectInfo, existing) ?? userId ?? null;
|
|
2451
|
+
|
|
2452
|
+
if (userId && ownerUserId && ownerUserId !== userId) {
|
|
2453
|
+
throw new Error('You do not have permission to delete this trashed project');
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
const trashMeta = existing?.metadata?.trash || projectInfo?.trash;
|
|
2457
|
+
if (!trashMeta?.trashedAt) {
|
|
2458
|
+
throw new Error('Project is not in trash');
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
if (mode === 'physical') {
|
|
2462
|
+
if (trashMeta.originalPath && await pathExists(trashMeta.originalPath)) {
|
|
2463
|
+
const storedInstanceId = trashMeta.instanceId || projectInfo?.trash?.instanceId || null;
|
|
2464
|
+
if (!storedInstanceId) {
|
|
2465
|
+
throw new Error('Cannot safely delete project files because this trash entry has no recorded instance identity. Use logical delete instead.');
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
const currentInstanceId = await readProjectInstanceId(trashMeta.originalPath);
|
|
2469
|
+
if (!currentInstanceId || currentInstanceId !== storedInstanceId) {
|
|
2470
|
+
throw new Error('Project files at the original path no longer match this trash entry. Refusing physical delete.');
|
|
2471
|
+
}
|
|
2472
|
+
|
|
2473
|
+
await fs.rm(trashMeta.originalPath, { recursive: true, force: true });
|
|
2474
|
+
|
|
2475
|
+
try {
|
|
2476
|
+
const codexSessions = await getCodexSessions(trashMeta.originalPath, { limit: 0 });
|
|
2477
|
+
for (const session of codexSessions) {
|
|
2478
|
+
try {
|
|
2479
|
+
await deleteCodexSession(session.id);
|
|
2480
|
+
} catch (err) {
|
|
2481
|
+
console.warn(`Failed to delete Codex session ${session.id}:`, err.message);
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
} catch (err) {
|
|
2485
|
+
console.warn('Failed to delete Codex sessions:', err.message);
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
try {
|
|
2489
|
+
const hash = crypto.createHash('md5').update(trashMeta.originalPath).digest('hex');
|
|
2490
|
+
const cursorProjectDir = path.join(os.homedir(), '.cursor', 'chats', hash);
|
|
2491
|
+
await fs.rm(cursorProjectDir, { recursive: true, force: true });
|
|
2492
|
+
} catch (_) {
|
|
2493
|
+
// Ignore missing Cursor artifacts
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
try {
|
|
2498
|
+
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
|
2499
|
+
await fs.rm(projectDir, { recursive: true, force: true });
|
|
2500
|
+
} catch (err) {
|
|
2501
|
+
console.warn(`Failed to delete Claude project dir for ${projectName}:`, err.message);
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
await mutateProjectConfig((nextConfig) => {
|
|
2505
|
+
delete nextConfig[projectName];
|
|
2506
|
+
clearDeletedProjectMetadata(nextConfig, projectName);
|
|
2507
|
+
});
|
|
2508
|
+
projectDb.deleteProject(projectName);
|
|
2509
|
+
sessionDb.deleteSessionsByProject(projectName);
|
|
2510
|
+
projectDirectoryCache.delete(projectName);
|
|
2511
|
+
return true;
|
|
2512
|
+
}
|
|
2513
|
+
|
|
2514
|
+
const deletedAt = new Date().toISOString();
|
|
2515
|
+
await mutateProjectConfig((nextConfig) => {
|
|
2516
|
+
const deletedProjects = getDeletedProjectsStore(nextConfig);
|
|
2517
|
+
deletedProjects[projectName] = {
|
|
2518
|
+
deletedAt,
|
|
2519
|
+
ownerUserId,
|
|
2520
|
+
originalPath: trashMeta.originalPath || projectInfo?.originalPath || existing?.path || '',
|
|
2521
|
+
displayName: existing?.display_name || projectInfo?.displayName || trashMeta.displayName || projectName,
|
|
2522
|
+
};
|
|
2523
|
+
delete nextConfig[projectName];
|
|
2524
|
+
});
|
|
2525
|
+
|
|
2526
|
+
projectDb.deleteProject(projectName);
|
|
2527
|
+
sessionDb.deleteSessionsByProject(projectName);
|
|
2528
|
+
projectDirectoryCache.delete(projectName);
|
|
2529
|
+
return true;
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
/**
|
|
2533
|
+
* Create .claude, .agents, .cursor and their skills subdirs in the project,
|
|
2534
|
+
* and symlink each Dr. Claw skill directory into those skills subdirs.
|
|
2535
|
+
* Also creates pipeline folders: Survey, Ideation, Experiment, Publication, Promotion.
|
|
2536
|
+
* Failures are logged but do not throw (project add still succeeds).
|
|
2537
|
+
*/
|
|
2538
|
+
async function collectSkillDirs(baseDir) {
|
|
2539
|
+
const results = []; // { name, absolutePath }
|
|
2540
|
+
|
|
2541
|
+
async function walk(dir) {
|
|
2542
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
2543
|
+
const hasSkillMd = entries.some(e => e.isFile() && e.name === 'SKILL.md');
|
|
2544
|
+
if (hasSkillMd) {
|
|
2545
|
+
results.push({ name: path.basename(dir), absolutePath: dir });
|
|
2546
|
+
return; // Don't recurse deeper into a skill directory
|
|
2547
|
+
}
|
|
2548
|
+
for (const entry of entries) {
|
|
2549
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
2550
|
+
await walk(path.join(dir, entry.name));
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
await walk(baseDir);
|
|
2556
|
+
return results;
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
/**
|
|
2560
|
+
* Load the set of core (platform-native) skill names from skill-tag-mapping.json.
|
|
2561
|
+
* Returns a Set of skill directory names that are considered "core pipeline" skills.
|
|
2562
|
+
*/
|
|
2563
|
+
function getCoreSkillNames() {
|
|
2564
|
+
try {
|
|
2565
|
+
const mappingPath = path.join(DRCLAW_SKILLS_DIR, 'skill-tag-mapping.json');
|
|
2566
|
+
const raw = fsSync.readFileSync(mappingPath, 'utf8');
|
|
2567
|
+
const mapping = JSON.parse(raw);
|
|
2568
|
+
const names = new Set(mapping.platformNativeSkills || []);
|
|
2569
|
+
// inno-pipeline-planner is always core but not in platformNativeSkills
|
|
2570
|
+
names.add('inno-pipeline-planner');
|
|
2571
|
+
// bioinformatics-init-analysis resolves to dir name 'init-analysis' via collectSkillDirs
|
|
2572
|
+
names.add('init-analysis');
|
|
2573
|
+
return names;
|
|
2574
|
+
} catch {
|
|
2575
|
+
return new Set();
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
/**
|
|
2580
|
+
* Generate a compact skills-index.md for the .agents/skills/ directory.
|
|
2581
|
+
* Reads YAML frontmatter (name, description) from each SKILL.md and produces
|
|
2582
|
+
* a markdown table grouped by Core Pipeline Skills vs Library Skills.
|
|
2583
|
+
*
|
|
2584
|
+
* @param {Array<{name: string, absolutePath: string}>} skillDirs
|
|
2585
|
+
* @returns {string} Markdown content for skills-index.md
|
|
2586
|
+
*/
|
|
2587
|
+
async function generateSkillsIndex(skillDirs) {
|
|
2588
|
+
const matter = (await import('gray-matter')).default;
|
|
2589
|
+
const coreNames = getCoreSkillNames();
|
|
2590
|
+
|
|
2591
|
+
const coreSkills = [];
|
|
2592
|
+
const librarySkills = [];
|
|
2593
|
+
|
|
2594
|
+
for (const { name, absolutePath } of skillDirs) {
|
|
2595
|
+
const skillMdPath = path.join(absolutePath, 'SKILL.md');
|
|
2596
|
+
let skillName = name;
|
|
2597
|
+
let description = '';
|
|
2598
|
+
try {
|
|
2599
|
+
const content = await fs.readFile(skillMdPath, 'utf8');
|
|
2600
|
+
const { data } = matter(content);
|
|
2601
|
+
if (data.name) skillName = data.name;
|
|
2602
|
+
if (data.description) {
|
|
2603
|
+
// Collapse newlines (YAML block scalars) and escape pipe chars for markdown tables
|
|
2604
|
+
const cleaned = data.description.replace(/[\r\n]+/g, ' ').replace(/\|/g, '/').trim();
|
|
2605
|
+
description = cleaned.length > 120
|
|
2606
|
+
? cleaned.slice(0, 117) + '...'
|
|
2607
|
+
: cleaned;
|
|
2608
|
+
}
|
|
2609
|
+
} catch {
|
|
2610
|
+
// Skip skills with unreadable SKILL.md
|
|
2611
|
+
continue;
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
const entry = { dirName: name, skillName, description };
|
|
2615
|
+
if (coreNames.has(name)) {
|
|
2616
|
+
coreSkills.push(entry);
|
|
2617
|
+
} else {
|
|
2618
|
+
librarySkills.push(entry);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
|
|
2622
|
+
coreSkills.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
2623
|
+
librarySkills.sort((a, b) => a.dirName.localeCompare(b.dirName));
|
|
2624
|
+
|
|
2625
|
+
const lines = [
|
|
2626
|
+
'# Skills Index',
|
|
2627
|
+
'',
|
|
2628
|
+
'> **Do NOT read all SKILL.md files at once.** Use this index to find the right skill, then read only that one.',
|
|
2629
|
+
'',
|
|
2630
|
+
'## Core Pipeline Skills',
|
|
2631
|
+
'',
|
|
2632
|
+
'| Skill | Path | Description |',
|
|
2633
|
+
'|-------|------|-------------|',
|
|
2634
|
+
];
|
|
2635
|
+
for (const s of coreSkills) {
|
|
2636
|
+
lines.push(`| ${s.skillName} | \`.agents/skills/${s.dirName}/SKILL.md\` | ${s.description} |`);
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
lines.push('', '## Library Skills', '');
|
|
2640
|
+
lines.push('| Skill | Path | Description |');
|
|
2641
|
+
lines.push('|-------|------|-------------|');
|
|
2642
|
+
for (const s of librarySkills) {
|
|
2643
|
+
lines.push(`| ${s.skillName} | \`.agents/skills/library/${s.dirName}/SKILL.md\` | ${s.description} |`);
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
lines.push('');
|
|
2647
|
+
return lines.join('\n');
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
async function ensureProjectSkillLinks(projectPath) {
|
|
2651
|
+
try {
|
|
2652
|
+
for (const dir of PROJECT_PIPELINE_FOLDERS) {
|
|
2653
|
+
await fs.mkdir(path.join(projectPath, dir), { recursive: true });
|
|
2654
|
+
}
|
|
2655
|
+
// Create preset research subdirs so the workspace structure is ready to use.
|
|
2656
|
+
const presetSubdirs = [
|
|
2657
|
+
'Survey/references',
|
|
2658
|
+
'Survey/reports',
|
|
2659
|
+
'Ideation/ideas',
|
|
2660
|
+
'Ideation/references',
|
|
2661
|
+
'Experiment/code_references',
|
|
2662
|
+
'Experiment/datasets',
|
|
2663
|
+
'Experiment/core_code',
|
|
2664
|
+
'Experiment/analysis',
|
|
2665
|
+
'Publication/paper',
|
|
2666
|
+
'Promotion/homepage',
|
|
2667
|
+
'Promotion/slides',
|
|
2668
|
+
'Promotion/audio',
|
|
2669
|
+
'Promotion/video'
|
|
2670
|
+
];
|
|
2671
|
+
for (const rel of presetSubdirs) {
|
|
2672
|
+
await fs.mkdir(path.join(projectPath, rel), { recursive: true });
|
|
2673
|
+
}
|
|
2674
|
+
} catch (err) {
|
|
2675
|
+
console.error('[projects] Failed to create pipeline folders or preset subdirs:', err.message);
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2678
|
+
// Keep creating instance.json for new projects.
|
|
2679
|
+
const instancePath = path.join(projectPath, 'instance.json');
|
|
2680
|
+
try {
|
|
2681
|
+
const projectBasename = path.basename(projectPath);
|
|
2682
|
+
const createdAt = new Date().toISOString();
|
|
2683
|
+
const instanceId = `${projectBasename}_${createdAt.replace(/[:.]/g, '-')}`;
|
|
2684
|
+
const instanceTemplate = {
|
|
2685
|
+
instance_id: instanceId,
|
|
2686
|
+
idea_maturity: '',
|
|
2687
|
+
created_at: createdAt,
|
|
2688
|
+
instance: instancePath,
|
|
2689
|
+
category: '',
|
|
2690
|
+
Survey: {
|
|
2691
|
+
references: path.join(projectPath, 'Survey', 'references'),
|
|
2692
|
+
reports: path.join(projectPath, 'Survey', 'reports')
|
|
2693
|
+
},
|
|
2694
|
+
Ideation: {
|
|
2695
|
+
ideas: path.join(projectPath, 'Ideation', 'ideas'),
|
|
2696
|
+
references: path.join(projectPath, 'Ideation', 'references')
|
|
2697
|
+
},
|
|
2698
|
+
Experiment: {
|
|
2699
|
+
code_references: path.join(projectPath, 'Experiment', 'code_references'),
|
|
2700
|
+
datasets: path.join(projectPath, 'Experiment', 'datasets'),
|
|
2701
|
+
core_code: path.join(projectPath, 'Experiment', 'core_code'),
|
|
2702
|
+
analysis: path.join(projectPath, 'Experiment', 'analysis')
|
|
2703
|
+
},
|
|
2704
|
+
Publication: {
|
|
2705
|
+
paper: path.join(projectPath, 'Publication', 'paper')
|
|
2706
|
+
},
|
|
2707
|
+
Promotion: {
|
|
2708
|
+
homepage: path.join(projectPath, 'Promotion', 'homepage'),
|
|
2709
|
+
slides: path.join(projectPath, 'Promotion', 'slides'),
|
|
2710
|
+
audio: path.join(projectPath, 'Promotion', 'audio'),
|
|
2711
|
+
video: path.join(projectPath, 'Promotion', 'video')
|
|
2712
|
+
}
|
|
2713
|
+
};
|
|
2714
|
+
const hasInstance = await fs.access(instancePath).then(() => true).catch(() => false);
|
|
2715
|
+
if (!hasInstance) {
|
|
2716
|
+
await fs.writeFile(instancePath, JSON.stringify(instanceTemplate, null, 2), 'utf8');
|
|
2717
|
+
}
|
|
2718
|
+
} catch (err) {
|
|
2719
|
+
console.error('[projects] Failed to create instance.json:', err.message);
|
|
2720
|
+
}
|
|
2721
|
+
|
|
2722
|
+
// Generate agent instruction templates (CLAUDE.md, .cursor/rules/project.md, AGENTS.md)
|
|
2723
|
+
try {
|
|
2724
|
+
const { writeProjectTemplates } = await import('./templates/index.js');
|
|
2725
|
+
await writeProjectTemplates(projectPath);
|
|
2726
|
+
} catch (err) {
|
|
2727
|
+
console.error('[projects] Failed to write agent templates:', err.message);
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
try {
|
|
2731
|
+
await fs.access(DRCLAW_SKILLS_DIR);
|
|
2732
|
+
} catch (err) {
|
|
2733
|
+
if (err.code === 'ENOENT') {
|
|
2734
|
+
console.warn('[projects] Dr. Claw skills dir not found, skipping skill symlinks:', DRCLAW_SKILLS_DIR);
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
console.error('[projects] Cannot access Dr. Claw skills dir:', err.message);
|
|
2738
|
+
return;
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
try {
|
|
2742
|
+
const skillDirs = await collectSkillDirs(DRCLAW_SKILLS_DIR);
|
|
2743
|
+
if (skillDirs.length === 0) return;
|
|
2744
|
+
|
|
2745
|
+
// Warn about name collisions
|
|
2746
|
+
const seen = new Map();
|
|
2747
|
+
for (const skill of skillDirs) {
|
|
2748
|
+
if (seen.has(skill.name)) {
|
|
2749
|
+
console.warn(`[projects] Skill name collision: "${skill.name}" found at both ${seen.get(skill.name)} and ${skill.absolutePath}`);
|
|
2750
|
+
} else {
|
|
2751
|
+
seen.set(skill.name, skill.absolutePath);
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
const coreNames = getCoreSkillNames();
|
|
2756
|
+
|
|
2757
|
+
for (const dir of PROJECT_SKILL_FOLDERS) {
|
|
2758
|
+
const skillsSubdir = path.join(projectPath, dir, 'skills');
|
|
2759
|
+
const isAgents = dir === '.agents';
|
|
2760
|
+
try {
|
|
2761
|
+
await fs.mkdir(skillsSubdir, { recursive: true });
|
|
2762
|
+
if (isAgents) {
|
|
2763
|
+
await fs.mkdir(path.join(skillsSubdir, 'library'), { recursive: true });
|
|
2764
|
+
}
|
|
2765
|
+
} catch (err) {
|
|
2766
|
+
console.error(`[projects] Failed to create ${dir}/skills:`, err.message);
|
|
2767
|
+
continue;
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
for (const { name, absolutePath } of skillDirs) {
|
|
2771
|
+
// For .agents/: core skills at top level, library skills under library/
|
|
2772
|
+
const linkPath = isAgents && !coreNames.has(name)
|
|
2773
|
+
? path.join(skillsSubdir, 'library', name)
|
|
2774
|
+
: path.join(skillsSubdir, name);
|
|
2775
|
+
try {
|
|
2776
|
+
try {
|
|
2777
|
+
await fs.unlink(linkPath);
|
|
2778
|
+
} catch (_) {
|
|
2779
|
+
// ignore if not exists or not a symlink
|
|
2780
|
+
}
|
|
2781
|
+
// Clean up stale top-level symlink when migrating library skills into library/
|
|
2782
|
+
if (isAgents && !coreNames.has(name)) {
|
|
2783
|
+
try { await fs.unlink(path.join(skillsSubdir, name)); } catch (_) {}
|
|
2784
|
+
}
|
|
2785
|
+
await fs.symlink(absolutePath, linkPath, 'dir');
|
|
2786
|
+
} catch (err) {
|
|
2787
|
+
console.error(`[projects] Failed to symlink ${name} in ${dir}/skills:`, err.message);
|
|
2788
|
+
}
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
// Write the skills index for .agents/ so Codex can discover skills lazily
|
|
2792
|
+
if (isAgents) {
|
|
2793
|
+
try {
|
|
2794
|
+
const indexContent = await generateSkillsIndex(skillDirs);
|
|
2795
|
+
await fs.writeFile(path.join(skillsSubdir, 'skills-index.md'), indexContent, 'utf8');
|
|
2796
|
+
} catch (err) {
|
|
2797
|
+
console.error('[projects] Failed to write skills-index.md:', err.message);
|
|
2798
|
+
}
|
|
2799
|
+
}
|
|
2800
|
+
|
|
2801
|
+
// Symlink JSON config files from Dr. Claw root into each project skills folder
|
|
2802
|
+
for (const jsonFile of ['skill-tag-mapping.json', 'stage-skill-map.json']) {
|
|
2803
|
+
const srcJson = path.join(DRCLAW_SKILLS_DIR, jsonFile);
|
|
2804
|
+
const destJson = path.join(skillsSubdir, jsonFile);
|
|
2805
|
+
try {
|
|
2806
|
+
await fs.access(srcJson);
|
|
2807
|
+
try { await fs.unlink(destJson); } catch (_) {}
|
|
2808
|
+
await fs.symlink(srcJson, destJson, 'file');
|
|
2809
|
+
} catch (err) {
|
|
2810
|
+
if (err.code !== 'ENOENT') {
|
|
2811
|
+
console.error(`[projects] Failed to symlink ${jsonFile} in ${dir}/skills:`, err.message);
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
} catch (err) {
|
|
2817
|
+
console.error('[projects] ensureProjectSkillLinks failed:', err.message);
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2821
|
+
// Add a project manually to the config (without creating folders)
|
|
2822
|
+
async function addProjectManually(projectPath, displayName = null, userId = null) {
|
|
2823
|
+
const { projectDb } = await import('./database/db.js');
|
|
2824
|
+
const absolutePath = path.resolve(projectPath);
|
|
2825
|
+
|
|
2826
|
+
try {
|
|
2827
|
+
await fs.access(absolutePath);
|
|
2828
|
+
} catch (error) {
|
|
2829
|
+
throw new Error(`Path does not exist: ${absolutePath}`);
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
const projectName = encodeProjectPath(absolutePath);
|
|
2833
|
+
|
|
2834
|
+
// Check for existing project with the same path (may have legacy encoded ID)
|
|
2835
|
+
const existingByPath = projectDb.getProjectByPath(absolutePath, userId);
|
|
2836
|
+
if (existingByPath) {
|
|
2837
|
+
if (existingByPath.id !== projectName) {
|
|
2838
|
+
// Legacy ID detected — migrate to new encoding
|
|
2839
|
+
projectDb.migrateProjectIdentity(existingByPath.id, projectName, absolutePath);
|
|
2840
|
+
}
|
|
2841
|
+
return {
|
|
2842
|
+
name: projectName,
|
|
2843
|
+
path: absolutePath,
|
|
2844
|
+
fullPath: absolutePath,
|
|
2845
|
+
displayName: displayName || existingByPath.display_name || await generateDisplayName(projectName, absolutePath),
|
|
2846
|
+
isManuallyAdded: Boolean(existingByPath.metadata?.manuallyAdded),
|
|
2847
|
+
createdAt: existingByPath.created_at,
|
|
2848
|
+
sessions: [],
|
|
2849
|
+
cursorSessions: [],
|
|
2850
|
+
alreadyExists: true,
|
|
2851
|
+
};
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
projectDb.upsertProject(projectName, userId, displayName, absolutePath, 0, new Date().toISOString(), { manuallyAdded: true });
|
|
2855
|
+
|
|
2856
|
+
await mutateProjectConfig((config) => {
|
|
2857
|
+
config[projectName] = {
|
|
2858
|
+
...(config[projectName] || {}),
|
|
2859
|
+
manuallyAdded: true,
|
|
2860
|
+
originalPath: absolutePath,
|
|
2861
|
+
ownerUserId: config[projectName]?.ownerUserId ?? userId ?? null,
|
|
2862
|
+
};
|
|
2863
|
+
|
|
2864
|
+
if (displayName) {
|
|
2865
|
+
config[projectName].displayName = displayName;
|
|
2866
|
+
}
|
|
2867
|
+
});
|
|
2868
|
+
|
|
2869
|
+
await ensureProjectSkillLinks(absolutePath);
|
|
2870
|
+
|
|
2871
|
+
let dirCreatedAt = null;
|
|
2872
|
+
try {
|
|
2873
|
+
const dirStat = await fs.stat(absolutePath);
|
|
2874
|
+
dirCreatedAt = dirStat.birthtime.toISOString();
|
|
2875
|
+
} catch (_) {}
|
|
2876
|
+
|
|
2877
|
+
return {
|
|
2878
|
+
name: projectName,
|
|
2879
|
+
path: absolutePath,
|
|
2880
|
+
fullPath: absolutePath,
|
|
2881
|
+
displayName: displayName || await generateDisplayName(projectName, absolutePath),
|
|
2882
|
+
isManuallyAdded: true,
|
|
2883
|
+
createdAt: dirCreatedAt,
|
|
2884
|
+
sessions: [],
|
|
2885
|
+
cursorSessions: []
|
|
2886
|
+
};
|
|
2887
|
+
}
|
|
2888
|
+
|
|
2889
|
+
// Fetch Cursor sessions for a given project path
|
|
2890
|
+
async function getCursorSessions(projectPath, options = {}) {
|
|
2891
|
+
try {
|
|
2892
|
+
const { sessionDb } = await import('./database/db.js');
|
|
2893
|
+
const { limit = 5, projectName = encodeProjectPath(projectPath) } = options;
|
|
2894
|
+
const candidatePaths = [projectPath];
|
|
2895
|
+
const legacyProjectPath = remapCurrentProjectPathToLegacy(projectPath);
|
|
2896
|
+
const legacyProjectName = legacyProjectPath ? encodeProjectPath(legacyProjectPath) : null;
|
|
2897
|
+
if (legacyProjectPath && legacyProjectPath !== projectPath) {
|
|
2898
|
+
candidatePaths.push(legacyProjectPath);
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
const sessions = [];
|
|
2902
|
+
const seenSessionIds = new Set();
|
|
2903
|
+
const dbSessions = [
|
|
2904
|
+
...sessionDb.getSessionsByProject(projectName),
|
|
2905
|
+
...(legacyProjectName && legacyProjectName !== projectName
|
|
2906
|
+
? sessionDb.getSessionsByProject(legacyProjectName)
|
|
2907
|
+
: []),
|
|
2908
|
+
];
|
|
2909
|
+
const dbSessionMap = new Map(dbSessions.filter((session) => session.provider === 'cursor').map((session) => [session.id, session]));
|
|
2910
|
+
|
|
2911
|
+
for (const candidatePath of candidatePaths) {
|
|
2912
|
+
const cwdId = crypto.createHash('md5').update(candidatePath).digest('hex');
|
|
2913
|
+
const cursorChatsPath = path.join(os.homedir(), '.cursor', 'chats', cwdId);
|
|
2914
|
+
|
|
2915
|
+
try {
|
|
2916
|
+
await fs.access(cursorChatsPath);
|
|
2917
|
+
} catch (_) {
|
|
2918
|
+
continue;
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
const sessionDirs = await fs.readdir(cursorChatsPath);
|
|
2922
|
+
|
|
2923
|
+
for (const sessionId of sessionDirs) {
|
|
2924
|
+
if (seenSessionIds.has(sessionId)) {
|
|
2925
|
+
continue;
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
const sessionPath = path.join(cursorChatsPath, sessionId);
|
|
2929
|
+
const storeDbPath = path.join(sessionPath, 'store.db');
|
|
2930
|
+
|
|
2931
|
+
try {
|
|
2932
|
+
await fs.access(storeDbPath);
|
|
2933
|
+
|
|
2934
|
+
let dbStatMtimeMs = null;
|
|
2935
|
+
try {
|
|
2936
|
+
const stat = await fs.stat(storeDbPath);
|
|
2937
|
+
dbStatMtimeMs = stat.mtimeMs;
|
|
2938
|
+
} catch (_) {}
|
|
2939
|
+
|
|
2940
|
+
const db = await open({
|
|
2941
|
+
filename: storeDbPath,
|
|
2942
|
+
driver: sqlite3.Database,
|
|
2943
|
+
mode: sqlite3.OPEN_READONLY
|
|
2944
|
+
});
|
|
2945
|
+
|
|
2946
|
+
const metaRows = await db.all(`
|
|
2947
|
+
SELECT key, value FROM meta
|
|
2948
|
+
`);
|
|
2949
|
+
|
|
2950
|
+
const metadata = {};
|
|
2951
|
+
for (const row of metaRows) {
|
|
2952
|
+
if (!row.value) continue;
|
|
2953
|
+
|
|
2954
|
+
try {
|
|
2955
|
+
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
|
|
2956
|
+
if (hexMatch) {
|
|
2957
|
+
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
|
|
2958
|
+
metadata[row.key] = JSON.parse(jsonStr);
|
|
2959
|
+
} else {
|
|
2960
|
+
metadata[row.key] = row.value.toString();
|
|
2961
|
+
}
|
|
2962
|
+
} catch (_) {
|
|
2963
|
+
metadata[row.key] = row.value.toString();
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
const messageCountResult = await db.get(`
|
|
2968
|
+
SELECT COUNT(*) as count FROM blobs
|
|
2969
|
+
`);
|
|
2970
|
+
|
|
2971
|
+
await db.close();
|
|
2972
|
+
|
|
2973
|
+
const sessionName = metadata.title || metadata.sessionTitle || 'Untitled Session';
|
|
2974
|
+
let createdAt = null;
|
|
2975
|
+
if (metadata.createdAt) {
|
|
2976
|
+
createdAt = new Date(metadata.createdAt).toISOString();
|
|
2977
|
+
} else if (dbStatMtimeMs) {
|
|
2978
|
+
createdAt = new Date(dbStatMtimeMs).toISOString();
|
|
2979
|
+
} else {
|
|
2980
|
+
createdAt = new Date().toISOString();
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
sessions.push({
|
|
2984
|
+
id: sessionId,
|
|
2985
|
+
name: sessionName,
|
|
2986
|
+
createdAt,
|
|
2987
|
+
lastActivity: createdAt,
|
|
2988
|
+
messageCount: messageCountResult.count || 0,
|
|
2989
|
+
mode: dbSessionMap.has(sessionId)
|
|
2990
|
+
? (readExplicitSessionModeFromMetadata(dbSessionMap.get(sessionId).metadata) || 'research')
|
|
2991
|
+
: 'research',
|
|
2992
|
+
projectPath,
|
|
2993
|
+
tags: dbSessionMap.has(sessionId)
|
|
2994
|
+
? (Array.isArray(dbSessionMap.get(sessionId).tags) ? dbSessionMap.get(sessionId).tags : [])
|
|
2995
|
+
: [],
|
|
2996
|
+
});
|
|
2997
|
+
seenSessionIds.add(sessionId);
|
|
2998
|
+
} catch (error) {
|
|
2999
|
+
console.warn(`Could not read Cursor session ${sessionId}:`, error.message);
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
sessions.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
3005
|
+
return sessions.slice(0, limit);
|
|
3006
|
+
} catch (error) {
|
|
3007
|
+
console.error('Error fetching Cursor sessions:', error);
|
|
3008
|
+
return [];
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
|
|
3012
|
+
|
|
3013
|
+
// Fetch Gemini sessions for a given project path
|
|
3014
|
+
async function getGeminiSessions(projectPath, optionsOrUserId = null) {
|
|
3015
|
+
const options = optionsOrUserId && typeof optionsOrUserId === 'object' && !Array.isArray(optionsOrUserId)
|
|
3016
|
+
? optionsOrUserId
|
|
3017
|
+
: {};
|
|
3018
|
+
const { limit = 5, indexRef = null, syncIndex = false, sessionId: targetSessionId = null, projectName: providedProjectName = null } = options;
|
|
3019
|
+
const projectName = providedProjectName || encodeProjectPath(projectPath);
|
|
3020
|
+
try {
|
|
3021
|
+
const { sessionDb } = await import('./database/db.js');
|
|
3022
|
+
const normalizedProjectPath = await normalizeComparablePath(projectPath);
|
|
3023
|
+
const legacyProjectPath = remapCurrentProjectPathToLegacy(projectPath);
|
|
3024
|
+
const normalizedLegacyProjectPath = await normalizeComparablePath(legacyProjectPath);
|
|
3025
|
+
const legacyProjectName = legacyProjectPath ? encodeProjectPath(legacyProjectPath) : null;
|
|
3026
|
+
if (!normalizedProjectPath) {
|
|
3027
|
+
return [];
|
|
3028
|
+
}
|
|
3029
|
+
|
|
3030
|
+
if (indexRef && !indexRef.sessionsByProject) {
|
|
3031
|
+
indexRef.sessionsByProject = await buildGeminiSessionsIndex();
|
|
3032
|
+
}
|
|
3033
|
+
|
|
3034
|
+
const sessionsByProject = indexRef?.sessionsByProject || await buildGeminiSessionsIndex();
|
|
3035
|
+
const sessions = [...(sessionsByProject.get(normalizedProjectPath) || [])];
|
|
3036
|
+
|
|
3037
|
+
if (normalizedLegacyProjectPath && normalizedLegacyProjectPath !== normalizedProjectPath) {
|
|
3038
|
+
sessions.push(...(sessionsByProject.get(normalizedLegacyProjectPath) || []));
|
|
3039
|
+
}
|
|
3040
|
+
|
|
3041
|
+
const dbSessions = [
|
|
3042
|
+
...sessionDb.getSessionsByProject(projectName),
|
|
3043
|
+
...(legacyProjectName && legacyProjectName !== projectName
|
|
3044
|
+
? sessionDb.getSessionsByProject(legacyProjectName)
|
|
3045
|
+
: []),
|
|
3046
|
+
];
|
|
3047
|
+
const dbSessionMap = new Map(dbSessions.filter((session) => session.provider === 'gemini').map((session) => [session.id, session]));
|
|
3048
|
+
|
|
3049
|
+
const dedupedSessions = Array.from(new Map(sessions.map((session) => [session.id, session])).values())
|
|
3050
|
+
.map((session) => ({
|
|
3051
|
+
...session,
|
|
3052
|
+
mode: dbSessionMap.has(session.id)
|
|
3053
|
+
? (readExplicitSessionModeFromMetadata(dbSessionMap.get(session.id).metadata) || normalizeSessionMode(session.mode))
|
|
3054
|
+
: normalizeSessionMode(session.mode),
|
|
3055
|
+
projectPath,
|
|
3056
|
+
tags: dbSessionMap.has(session.id)
|
|
3057
|
+
? (Array.isArray(dbSessionMap.get(session.id).tags) ? dbSessionMap.get(session.id).tags : [])
|
|
3058
|
+
: (Array.isArray(session.tags) ? session.tags : []),
|
|
3059
|
+
}));
|
|
3060
|
+
const filteredSessions = targetSessionId
|
|
3061
|
+
? dedupedSessions.filter((session) => session.id === targetSessionId)
|
|
3062
|
+
: dedupedSessions;
|
|
3063
|
+
|
|
3064
|
+
if (syncIndex) {
|
|
3065
|
+
const { sessionDb } = await import('./database/db.js');
|
|
3066
|
+
const projectName = providedProjectName || encodeProjectPath(projectPath);
|
|
3067
|
+
await Promise.allSettled(
|
|
3068
|
+
filteredSessions.map(async (session) => {
|
|
3069
|
+
const indexedSession = sessionDb.getSessionById(session.id);
|
|
3070
|
+
await reconcileIndexedSessionFromSource(projectName, 'gemini', {
|
|
3071
|
+
...session,
|
|
3072
|
+
summary: session.summary || session.name,
|
|
3073
|
+
}, indexedSession, projectPath);
|
|
3074
|
+
})
|
|
3075
|
+
);
|
|
3076
|
+
}
|
|
3077
|
+
|
|
3078
|
+
return limit > 0 ? filteredSessions.slice(0, limit) : filteredSessions;
|
|
3079
|
+
} catch (error) {
|
|
3080
|
+
console.error('Error fetching Gemini sessions:', error);
|
|
3081
|
+
return [];
|
|
3082
|
+
}
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
async function buildGeminiSessionsIndex() {
|
|
3086
|
+
const { sessionDb } = await import('./database/db.js');
|
|
3087
|
+
const geminiSessionsDir = path.join(os.homedir(), '.gemini', 'sessions');
|
|
3088
|
+
const sessionsByProject = new Map();
|
|
3089
|
+
|
|
3090
|
+
try {
|
|
3091
|
+
await fs.access(geminiSessionsDir);
|
|
3092
|
+
} catch (error) {
|
|
3093
|
+
return sessionsByProject;
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
const files = await fs.readdir(geminiSessionsDir);
|
|
3097
|
+
|
|
3098
|
+
for (const file of files) {
|
|
3099
|
+
if (!file.endsWith('.jsonl')) {
|
|
3100
|
+
continue;
|
|
3101
|
+
}
|
|
3102
|
+
|
|
3103
|
+
const sessionId = path.basename(file, '.jsonl');
|
|
3104
|
+
const filePath = path.join(geminiSessionsDir, file);
|
|
3105
|
+
|
|
3106
|
+
try {
|
|
3107
|
+
const stats = await fs.stat(filePath);
|
|
3108
|
+
const indexedSession = sessionDb.getSessionById(sessionId);
|
|
3109
|
+
const indexedMessageCount = Number(indexedSession?.message_count ?? indexedSession?.messageCount ?? 0);
|
|
3110
|
+
const matchedProjectPaths = new Set();
|
|
3111
|
+
|
|
3112
|
+
let explicitTitle = indexedSession?.display_name || null;
|
|
3113
|
+
let firstMessageText = null;
|
|
3114
|
+
let messageCount = 0;
|
|
3115
|
+
// Recovery order: DB metadata -> JSONL metadata -> context marker -> high-confidence heuristic -> default.
|
|
3116
|
+
let detectedSessionMode = readExplicitSessionModeFromMetadata(indexedSession?.metadata);
|
|
3117
|
+
|
|
3118
|
+
const fileStream = fsSync.createReadStream(filePath);
|
|
3119
|
+
const rl = readline.createInterface({
|
|
3120
|
+
input: fileStream,
|
|
3121
|
+
crlfDelay: Infinity,
|
|
3122
|
+
});
|
|
3123
|
+
|
|
3124
|
+
let lineCount = 0;
|
|
3125
|
+
|
|
3126
|
+
for await (const line of rl) {
|
|
3127
|
+
lineCount++;
|
|
3128
|
+
if (lineCount > 2000) {
|
|
3129
|
+
break;
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
if (!line.trim()) {
|
|
3133
|
+
continue;
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
try {
|
|
3137
|
+
const entry = JSON.parse(line);
|
|
3138
|
+
const metadataMode = readExplicitSessionModeFromMetadata(entry.payload || entry);
|
|
3139
|
+
if (metadataMode) {
|
|
3140
|
+
detectedSessionMode = metadataMode;
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
const sessionCwd = entry.cwd || entry.payload?.cwd;
|
|
3144
|
+
if (sessionCwd) {
|
|
3145
|
+
const normalizedSessionCwd = await normalizeComparablePath(sessionCwd);
|
|
3146
|
+
if (normalizedSessionCwd) {
|
|
3147
|
+
matchedProjectPaths.add(normalizedSessionCwd);
|
|
3148
|
+
}
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
const title = entry.summary || entry.title || entry.payload?.title || entry.payload?.summary;
|
|
3152
|
+
if (
|
|
3153
|
+
title &&
|
|
3154
|
+
typeof title === 'string' &&
|
|
3155
|
+
title.trim() &&
|
|
3156
|
+
!title.includes('Gemini Session') &&
|
|
3157
|
+
!title.includes('New Session')
|
|
3158
|
+
) {
|
|
3159
|
+
explicitTitle = stripInternalContextPrefix(title.trim());
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
if (!firstMessageText && (entry.role === 'user' || (entry.type === 'message' && entry.role === 'user'))) {
|
|
3163
|
+
const content = entry.content || entry.message?.content || entry.payload?.message?.content;
|
|
3164
|
+
const textContent = typeof content === 'string'
|
|
3165
|
+
? content
|
|
3166
|
+
: Array.isArray(content)
|
|
3167
|
+
? content.map((part) => part.text || (typeof part === 'string' ? part : '')).join(' ')
|
|
3168
|
+
: '';
|
|
3169
|
+
|
|
3170
|
+
if (textContent.trim()) {
|
|
3171
|
+
const modeFromContext = extractSessionModeFromText(textContent);
|
|
3172
|
+
if (modeFromContext) {
|
|
3173
|
+
detectedSessionMode = modeFromContext;
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
const cleaned = stripInternalContextPrefix(textContent.trim(), false);
|
|
3177
|
+
if (cleaned && !cleaned.includes('Base directory for this skill:') && !cleaned.startsWith('<command-name>')) {
|
|
3178
|
+
if (!detectedSessionMode) {
|
|
3179
|
+
detectedSessionMode = inferSessionModeFromUserMessage(cleaned);
|
|
3180
|
+
}
|
|
3181
|
+
const helpMatch = cleaned.match(/Please help me with ["'](.*?)["']/);
|
|
3182
|
+
firstMessageText = helpMatch ? helpMatch[1] : cleaned.split('\n')[0].replace(/#+\s*/, '').trim();
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
const isUserMessage = entry.role === 'user' || (entry.type === 'message' && entry.role === 'user');
|
|
3188
|
+
const isAssistantMessage = entry.role === 'assistant'
|
|
3189
|
+
|| entry.message?.role === 'assistant'
|
|
3190
|
+
|| (entry.type === 'message' && entry.role === 'assistant');
|
|
3191
|
+
|
|
3192
|
+
if (isUserMessage || isAssistantMessage) {
|
|
3193
|
+
messageCount++;
|
|
3194
|
+
}
|
|
3195
|
+
} catch (error) {}
|
|
3196
|
+
}
|
|
3197
|
+
|
|
3198
|
+
if (matchedProjectPaths.size === 0) {
|
|
3199
|
+
continue;
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
let finalName = explicitTitle || firstMessageText;
|
|
3203
|
+
if (finalName) {
|
|
3204
|
+
finalName = finalName.replace(/[\*\_\`]/g, '');
|
|
3205
|
+
if (finalName.length > 50) {
|
|
3206
|
+
finalName = `${finalName.substring(0, 47)}...`;
|
|
3207
|
+
}
|
|
3208
|
+
} else {
|
|
3209
|
+
finalName = 'Untitled Session';
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
const sessionMode = detectedSessionMode || 'research';
|
|
3213
|
+
const resolvedMessageCount = Math.max(indexedMessageCount, messageCount);
|
|
3214
|
+
const session = {
|
|
3215
|
+
id: sessionId,
|
|
3216
|
+
name: finalName,
|
|
3217
|
+
createdAt: stats.birthtime.toISOString(),
|
|
3218
|
+
lastActivity: stats.mtime.toISOString(),
|
|
3219
|
+
messageCount: resolvedMessageCount,
|
|
3220
|
+
mode: sessionMode,
|
|
3221
|
+
filePath,
|
|
3222
|
+
__provider: 'gemini',
|
|
3223
|
+
};
|
|
3224
|
+
|
|
3225
|
+
for (const normalizedProjectPath of matchedProjectPaths) {
|
|
3226
|
+
if (!sessionsByProject.has(normalizedProjectPath)) {
|
|
3227
|
+
sessionsByProject.set(normalizedProjectPath, []);
|
|
3228
|
+
}
|
|
3229
|
+
sessionsByProject.get(normalizedProjectPath).push(session);
|
|
3230
|
+
}
|
|
3231
|
+
} catch (error) {}
|
|
3232
|
+
}
|
|
3233
|
+
|
|
3234
|
+
for (const sessions of sessionsByProject.values()) {
|
|
3235
|
+
sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
return sessionsByProject;
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
async function normalizeComparablePath(inputPath) {
|
|
3242
|
+
if (!inputPath || typeof inputPath !== 'string') {
|
|
3243
|
+
return '';
|
|
3244
|
+
}
|
|
3245
|
+
|
|
3246
|
+
const withoutLongPathPrefix = inputPath.startsWith('\\\\?\\')
|
|
3247
|
+
? inputPath.slice(4)
|
|
3248
|
+
: inputPath;
|
|
3249
|
+
const normalized = path.normalize(withoutLongPathPrefix.trim());
|
|
3250
|
+
|
|
3251
|
+
if (!normalized) {
|
|
3252
|
+
return '';
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
const resolved = path.resolve(normalized);
|
|
3256
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
async function findCodexJsonlFiles(dir) {
|
|
3260
|
+
const files = [];
|
|
3261
|
+
|
|
3262
|
+
try {
|
|
3263
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
3264
|
+
for (const entry of entries) {
|
|
3265
|
+
const fullPath = path.join(dir, entry.name);
|
|
3266
|
+
if (entry.isDirectory()) {
|
|
3267
|
+
files.push(...await findCodexJsonlFiles(fullPath));
|
|
3268
|
+
} else if (entry.name.endsWith('.jsonl')) {
|
|
3269
|
+
files.push(fullPath);
|
|
3270
|
+
}
|
|
3271
|
+
}
|
|
3272
|
+
} catch (error) {
|
|
3273
|
+
// Skip directories we can't read
|
|
3274
|
+
}
|
|
3275
|
+
|
|
3276
|
+
return files;
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
async function buildCodexSessionsIndex() {
|
|
3280
|
+
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
|
3281
|
+
const sessionsByProject = new Map();
|
|
3282
|
+
|
|
3283
|
+
try {
|
|
3284
|
+
await fs.access(codexSessionsDir);
|
|
3285
|
+
} catch (error) {
|
|
3286
|
+
return sessionsByProject;
|
|
3287
|
+
}
|
|
3288
|
+
|
|
3289
|
+
const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir);
|
|
3290
|
+
|
|
3291
|
+
for (const filePath of jsonlFiles) {
|
|
3292
|
+
try {
|
|
3293
|
+
const sessionData = await parseCodexSessionFile(filePath);
|
|
3294
|
+
if (!sessionData || !sessionData.id) {
|
|
3295
|
+
continue;
|
|
3296
|
+
}
|
|
3297
|
+
|
|
3298
|
+
const normalizedProjectPath = await normalizeComparablePath(sessionData.cwd);
|
|
3299
|
+
if (!normalizedProjectPath) {
|
|
3300
|
+
continue;
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
const session = {
|
|
3304
|
+
id: sessionData.id,
|
|
3305
|
+
summary: sessionData.summary || 'Codex Session',
|
|
3306
|
+
messageCount: sessionData.messageCount || 0,
|
|
3307
|
+
lastActivity: sessionData.timestamp ? new Date(sessionData.timestamp) : new Date(),
|
|
3308
|
+
cwd: sessionData.cwd,
|
|
3309
|
+
model: sessionData.model,
|
|
3310
|
+
mode: normalizeSessionMode(sessionData.mode),
|
|
3311
|
+
filePath,
|
|
3312
|
+
provider: 'codex',
|
|
3313
|
+
};
|
|
3314
|
+
|
|
3315
|
+
if (!sessionsByProject.has(normalizedProjectPath)) {
|
|
3316
|
+
sessionsByProject.set(normalizedProjectPath, []);
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
sessionsByProject.get(normalizedProjectPath).push(session);
|
|
3320
|
+
} catch (error) {
|
|
3321
|
+
console.warn(`Could not parse Codex session file ${filePath}:`, error.message);
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
for (const sessions of sessionsByProject.values()) {
|
|
3326
|
+
sessions.sort((a, b) => new Date(b.lastActivity) - new Date(a.lastActivity));
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
return sessionsByProject;
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
// Fetch Codex sessions for a given project path
|
|
3333
|
+
async function getCodexSessions(projectPath, options = {}) {
|
|
3334
|
+
const { limit = 5, indexRef = null, syncIndex = false, sessionId: targetSessionId = null, projectName: providedProjectName = null } = options;
|
|
3335
|
+
const projectName = providedProjectName || encodeProjectPath(projectPath);
|
|
3336
|
+
try {
|
|
3337
|
+
const { sessionDb } = await import('./database/db.js');
|
|
3338
|
+
const normalizedProjectPath = await normalizeComparablePath(projectPath);
|
|
3339
|
+
const normalizedLegacyProjectPath = await normalizeComparablePath(remapCurrentProjectPathToLegacy(projectPath));
|
|
3340
|
+
const legacyProjectPath = remapCurrentProjectPathToLegacy(projectPath);
|
|
3341
|
+
const legacyProjectName = legacyProjectPath ? encodeProjectPath(legacyProjectPath) : null;
|
|
3342
|
+
if (!normalizedProjectPath) {
|
|
3343
|
+
return [];
|
|
3344
|
+
}
|
|
3345
|
+
|
|
3346
|
+
if (indexRef && !indexRef.sessionsByProject) {
|
|
3347
|
+
indexRef.sessionsByProject = await buildCodexSessionsIndex();
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
const sessionsByProject = indexRef?.sessionsByProject || await buildCodexSessionsIndex();
|
|
3351
|
+
const sessions = [...(sessionsByProject.get(normalizedProjectPath) || [])];
|
|
3352
|
+
|
|
3353
|
+
if (normalizedLegacyProjectPath && normalizedLegacyProjectPath !== normalizedProjectPath) {
|
|
3354
|
+
sessions.push(...(sessionsByProject.get(normalizedLegacyProjectPath) || []));
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3357
|
+
// Return limited sessions for performance (0 = unlimited for deletion)
|
|
3358
|
+
const dbSessions = [
|
|
3359
|
+
...sessionDb.getSessionsByProject(projectName),
|
|
3360
|
+
...(legacyProjectName && legacyProjectName !== projectName
|
|
3361
|
+
? sessionDb.getSessionsByProject(legacyProjectName)
|
|
3362
|
+
: []),
|
|
3363
|
+
];
|
|
3364
|
+
const dbSessionMap = new Map(dbSessions.filter((session) => session.provider === 'codex').map((session) => [session.id, session]));
|
|
3365
|
+
const dedupedSessions = Array.from(new Map(sessions.map((session) => [session.id, session])).values()).map((session) => ({
|
|
3366
|
+
...session,
|
|
3367
|
+
mode: dbSessionMap.has(session.id)
|
|
3368
|
+
? (readExplicitSessionModeFromMetadata(dbSessionMap.get(session.id).metadata) || normalizeSessionMode(session.mode))
|
|
3369
|
+
: normalizeSessionMode(session.mode),
|
|
3370
|
+
tags: dbSessionMap.has(session.id)
|
|
3371
|
+
? (Array.isArray(dbSessionMap.get(session.id).tags) ? dbSessionMap.get(session.id).tags : [])
|
|
3372
|
+
: (Array.isArray(session.tags) ? session.tags : []),
|
|
3373
|
+
}));
|
|
3374
|
+
const filteredSessions = targetSessionId
|
|
3375
|
+
? dedupedSessions.filter((session) => session.id === targetSessionId)
|
|
3376
|
+
: dedupedSessions;
|
|
3377
|
+
|
|
3378
|
+
if (syncIndex) {
|
|
3379
|
+
await Promise.allSettled(
|
|
3380
|
+
filteredSessions.map(async (session) => {
|
|
3381
|
+
const indexedSession = sessionDb.getSessionById(session.id);
|
|
3382
|
+
await reconcileIndexedSessionFromSource(projectName, 'codex', {
|
|
3383
|
+
...session,
|
|
3384
|
+
summary: session.summary || session.name,
|
|
3385
|
+
createdAt: session.createdAt || session.lastActivity,
|
|
3386
|
+
}, indexedSession, projectPath);
|
|
3387
|
+
})
|
|
3388
|
+
);
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
return limit > 0 ? filteredSessions.slice(0, limit) : filteredSessions;
|
|
3392
|
+
|
|
3393
|
+
} catch (error) {
|
|
3394
|
+
console.error('Error fetching Codex sessions:', error);
|
|
3395
|
+
return [];
|
|
3396
|
+
}
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3399
|
+
// Parse a Codex session JSONL file to extract metadata
|
|
3400
|
+
async function parseCodexSessionFile(filePath) {
|
|
3401
|
+
try {
|
|
3402
|
+
const fileStream = fsSync.createReadStream(filePath);
|
|
3403
|
+
const rl = readline.createInterface({
|
|
3404
|
+
input: fileStream,
|
|
3405
|
+
crlfDelay: Infinity
|
|
3406
|
+
});
|
|
3407
|
+
|
|
3408
|
+
let sessionMeta = null;
|
|
3409
|
+
let lastTimestamp = null;
|
|
3410
|
+
let lastUserMessage = null;
|
|
3411
|
+
let messageCount = 0;
|
|
3412
|
+
let detectedSessionMode = null;
|
|
3413
|
+
|
|
3414
|
+
for await (const line of rl) {
|
|
3415
|
+
if (line.trim()) {
|
|
3416
|
+
try {
|
|
3417
|
+
const entry = JSON.parse(line);
|
|
3418
|
+
|
|
3419
|
+
// Track timestamp
|
|
3420
|
+
if (entry.timestamp) {
|
|
3421
|
+
lastTimestamp = entry.timestamp;
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3424
|
+
// Extract session metadata
|
|
3425
|
+
if (entry.type === 'session_meta' && entry.payload) {
|
|
3426
|
+
sessionMeta = {
|
|
3427
|
+
id: entry.payload.id,
|
|
3428
|
+
cwd: entry.payload.cwd,
|
|
3429
|
+
model: entry.payload.model || entry.payload.model_provider,
|
|
3430
|
+
timestamp: entry.timestamp,
|
|
3431
|
+
git: entry.payload.git
|
|
3432
|
+
};
|
|
3433
|
+
}
|
|
3434
|
+
|
|
3435
|
+
// Count messages and extract user messages for summary
|
|
3436
|
+
if (entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
|
|
3437
|
+
messageCount++;
|
|
3438
|
+
if (entry.payload.message) {
|
|
3439
|
+
const modeFromMessage = extractSessionModeFromText(entry.payload.message);
|
|
3440
|
+
if (modeFromMessage) {
|
|
3441
|
+
detectedSessionMode = modeFromMessage;
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
const cleanedUserMessage = stripInternalContextPrefix(entry.payload.message, false);
|
|
3445
|
+
if (cleanedUserMessage && !isCodexSystemPromptContent(cleanedUserMessage)) {
|
|
3446
|
+
if (!detectedSessionMode) {
|
|
3447
|
+
detectedSessionMode = inferSessionModeFromUserMessage(cleanedUserMessage);
|
|
3448
|
+
}
|
|
3449
|
+
lastUserMessage = cleanedUserMessage;
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
if (entry.type === 'response_item' && entry.payload?.type === 'message' && entry.payload.role === 'assistant') {
|
|
3455
|
+
messageCount++;
|
|
3456
|
+
}
|
|
3457
|
+
|
|
3458
|
+
} catch (parseError) {
|
|
3459
|
+
// Skip malformed lines
|
|
3460
|
+
}
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
if (sessionMeta) {
|
|
3465
|
+
return {
|
|
3466
|
+
...sessionMeta,
|
|
3467
|
+
timestamp: lastTimestamp || sessionMeta.timestamp,
|
|
3468
|
+
mode: detectedSessionMode || 'research',
|
|
3469
|
+
summary: lastUserMessage ?
|
|
3470
|
+
(lastUserMessage.length > 50 ? lastUserMessage.substring(0, 50) + '...' : lastUserMessage) :
|
|
3471
|
+
'Codex Session',
|
|
3472
|
+
messageCount
|
|
3473
|
+
};
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
return null;
|
|
3477
|
+
|
|
3478
|
+
} catch (error) {
|
|
3479
|
+
console.error('Error parsing Codex session file:', error);
|
|
3480
|
+
return null;
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
/**
|
|
3485
|
+
* Detect system prompt / instruction content in Codex messages
|
|
3486
|
+
* (AGENTS.md, skill listings, instruction blocks)
|
|
3487
|
+
*/
|
|
3488
|
+
function isCodexSystemPromptContent(text) {
|
|
3489
|
+
if (!text || text.length < 200) return false;
|
|
3490
|
+
if (/^#\s+(AGENTS|SKILL|INSTRUCTIONS)/m.test(text)) return true;
|
|
3491
|
+
if (text.includes('<INSTRUCTIONS>') || text.includes('</INSTRUCTIONS>')) return true;
|
|
3492
|
+
if (/^#+\s+.*instructions\s+for\s+\//im.test(text)) return true;
|
|
3493
|
+
if (text.includes('Base directory for this skill:') && text.length > 500) return true;
|
|
3494
|
+
if (text.length > 2000 && /^\d+\)\s/m.test(text) && /\bskill\b/i.test(text)) return true;
|
|
3495
|
+
const skillPathCount = (text.match(/SKILL\.md\)/g) || []).length;
|
|
3496
|
+
if (skillPathCount >= 3) return true;
|
|
3497
|
+
if (text.includes('### How to use skills') || text.includes('## How to use skills')) return true;
|
|
3498
|
+
if (text.includes('Trigger rules:') && text.includes('skill') && text.length > 500) return true;
|
|
3499
|
+
return false;
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
// Get messages for a specific Codex session
|
|
3503
|
+
async function getCodexSessionMessages(sessionId, limit = null, offset = 0) {
|
|
3504
|
+
try {
|
|
3505
|
+
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
|
3506
|
+
|
|
3507
|
+
const findSessionFileByMetadata = async () => {
|
|
3508
|
+
const jsonlFiles = await findCodexJsonlFiles(codexSessionsDir);
|
|
3509
|
+
|
|
3510
|
+
let filenameMatch = null;
|
|
3511
|
+
for (const filePath of jsonlFiles) {
|
|
3512
|
+
if (path.basename(filePath).includes(sessionId)) {
|
|
3513
|
+
filenameMatch = filePath;
|
|
3514
|
+
break;
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
|
|
3518
|
+
if (filenameMatch) {
|
|
3519
|
+
return filenameMatch;
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3522
|
+
for (const filePath of jsonlFiles) {
|
|
3523
|
+
const sessionData = await parseCodexSessionFile(filePath);
|
|
3524
|
+
if (sessionData?.id === sessionId) {
|
|
3525
|
+
return filePath;
|
|
3526
|
+
}
|
|
3527
|
+
}
|
|
3528
|
+
|
|
3529
|
+
return null;
|
|
3530
|
+
};
|
|
3531
|
+
|
|
3532
|
+
const sessionFilePath = await findSessionFileByMetadata();
|
|
3533
|
+
|
|
3534
|
+
if (!sessionFilePath) {
|
|
3535
|
+
console.warn(`Codex session file not found for session ${sessionId}`);
|
|
3536
|
+
return { messages: [], total: 0, hasMore: false };
|
|
3537
|
+
}
|
|
3538
|
+
|
|
3539
|
+
const messages = [];
|
|
3540
|
+
let tokenUsage = null;
|
|
3541
|
+
const fileStream = fsSync.createReadStream(sessionFilePath);
|
|
3542
|
+
const rl = readline.createInterface({
|
|
3543
|
+
input: fileStream,
|
|
3544
|
+
crlfDelay: Infinity
|
|
3545
|
+
});
|
|
3546
|
+
|
|
3547
|
+
// Helper to extract text from Codex content array
|
|
3548
|
+
const extractText = (content) => {
|
|
3549
|
+
if (!Array.isArray(content)) return content;
|
|
3550
|
+
return content
|
|
3551
|
+
.map(item => {
|
|
3552
|
+
if (item.type === 'input_text' || item.type === 'output_text') {
|
|
3553
|
+
return item.text;
|
|
3554
|
+
}
|
|
3555
|
+
if (item.type === 'text') {
|
|
3556
|
+
return item.text;
|
|
3557
|
+
}
|
|
3558
|
+
return '';
|
|
3559
|
+
})
|
|
3560
|
+
.filter(Boolean)
|
|
3561
|
+
.join('\n');
|
|
3562
|
+
};
|
|
3563
|
+
|
|
3564
|
+
for await (const line of rl) {
|
|
3565
|
+
if (line.trim()) {
|
|
3566
|
+
try {
|
|
3567
|
+
const entry = JSON.parse(line);
|
|
3568
|
+
|
|
3569
|
+
// Extract token usage from token_count events (keep latest)
|
|
3570
|
+
if (entry.type === 'event_msg' && entry.payload?.type === 'token_count' && entry.payload?.info) {
|
|
3571
|
+
const info = entry.payload.info;
|
|
3572
|
+
if (info.total_token_usage) {
|
|
3573
|
+
tokenUsage = {
|
|
3574
|
+
used: info.total_token_usage.total_tokens || 0,
|
|
3575
|
+
total: info.model_context_window || 200000
|
|
3576
|
+
};
|
|
3577
|
+
}
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
// Extract messages from response_item
|
|
3581
|
+
if (entry.type === 'response_item' && entry.payload?.type === 'message') {
|
|
3582
|
+
const content = entry.payload.content;
|
|
3583
|
+
const role = entry.payload.role || 'assistant';
|
|
3584
|
+
const textContent = extractText(content);
|
|
3585
|
+
|
|
3586
|
+
// Skip system context messages (environment_context)
|
|
3587
|
+
if (textContent?.includes('<environment_context>')) {
|
|
3588
|
+
continue;
|
|
3589
|
+
}
|
|
3590
|
+
|
|
3591
|
+
// Skip system prompt / instruction content (AGENTS.md, skills listing, etc.)
|
|
3592
|
+
if (textContent && isCodexSystemPromptContent(textContent)) {
|
|
3593
|
+
continue;
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3596
|
+
// Only add if there's actual content
|
|
3597
|
+
if (textContent?.trim()) {
|
|
3598
|
+
messages.push({
|
|
3599
|
+
type: role === 'user' ? 'user' : 'assistant',
|
|
3600
|
+
timestamp: entry.timestamp,
|
|
3601
|
+
message: {
|
|
3602
|
+
role: role,
|
|
3603
|
+
content: textContent
|
|
3604
|
+
}
|
|
3605
|
+
});
|
|
3606
|
+
}
|
|
3607
|
+
}
|
|
3608
|
+
|
|
3609
|
+
// Skip Codex reasoning items - they are brief status notes, not useful to display
|
|
3610
|
+
|
|
3611
|
+
if (entry.type === 'response_item' && entry.payload?.type === 'function_call') {
|
|
3612
|
+
let toolName = entry.payload.name;
|
|
3613
|
+
let toolInput = entry.payload.arguments;
|
|
3614
|
+
|
|
3615
|
+
// Map Codex tool names to Claude equivalents
|
|
3616
|
+
if (toolName === 'shell_command') {
|
|
3617
|
+
toolName = 'Bash';
|
|
3618
|
+
try {
|
|
3619
|
+
const args = JSON.parse(entry.payload.arguments);
|
|
3620
|
+
toolInput = JSON.stringify({ command: args.command });
|
|
3621
|
+
} catch (e) {
|
|
3622
|
+
// Keep original if parsing fails
|
|
3623
|
+
}
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3626
|
+
messages.push({
|
|
3627
|
+
type: 'tool_use',
|
|
3628
|
+
timestamp: entry.timestamp,
|
|
3629
|
+
toolName: toolName,
|
|
3630
|
+
toolInput: toolInput,
|
|
3631
|
+
toolCallId: entry.payload.call_id
|
|
3632
|
+
});
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
if (entry.type === 'response_item' && entry.payload?.type === 'function_call_output') {
|
|
3636
|
+
messages.push({
|
|
3637
|
+
type: 'tool_result',
|
|
3638
|
+
timestamp: entry.timestamp,
|
|
3639
|
+
toolCallId: entry.payload.call_id,
|
|
3640
|
+
output: entry.payload.output
|
|
3641
|
+
});
|
|
3642
|
+
}
|
|
3643
|
+
|
|
3644
|
+
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call') {
|
|
3645
|
+
const toolName = entry.payload.name || 'custom_tool';
|
|
3646
|
+
const input = entry.payload.input || '';
|
|
3647
|
+
|
|
3648
|
+
if (toolName === 'apply_patch') {
|
|
3649
|
+
// Parse Codex patch format and convert to Claude Edit format
|
|
3650
|
+
const fileMatch = input.match(/\*\*\* Update File: (.+)/);
|
|
3651
|
+
const filePath = fileMatch ? fileMatch[1].trim() : 'unknown';
|
|
3652
|
+
|
|
3653
|
+
// Extract old and new content from patch
|
|
3654
|
+
const lines = input.split('\n');
|
|
3655
|
+
const oldLines = [];
|
|
3656
|
+
const newLines = [];
|
|
3657
|
+
|
|
3658
|
+
for (const line of lines) {
|
|
3659
|
+
if (line.startsWith('-') && !line.startsWith('---')) {
|
|
3660
|
+
oldLines.push(line.substring(1));
|
|
3661
|
+
} else if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
3662
|
+
newLines.push(line.substring(1));
|
|
3663
|
+
}
|
|
3664
|
+
}
|
|
3665
|
+
|
|
3666
|
+
messages.push({
|
|
3667
|
+
type: 'tool_use',
|
|
3668
|
+
timestamp: entry.timestamp,
|
|
3669
|
+
toolName: 'Edit',
|
|
3670
|
+
toolInput: JSON.stringify({
|
|
3671
|
+
file_path: filePath,
|
|
3672
|
+
old_string: oldLines.join('\n'),
|
|
3673
|
+
new_string: newLines.join('\n')
|
|
3674
|
+
}),
|
|
3675
|
+
toolCallId: entry.payload.call_id
|
|
3676
|
+
});
|
|
3677
|
+
} else {
|
|
3678
|
+
messages.push({
|
|
3679
|
+
type: 'tool_use',
|
|
3680
|
+
timestamp: entry.timestamp,
|
|
3681
|
+
toolName: toolName,
|
|
3682
|
+
toolInput: input,
|
|
3683
|
+
toolCallId: entry.payload.call_id
|
|
3684
|
+
});
|
|
3685
|
+
}
|
|
3686
|
+
}
|
|
3687
|
+
|
|
3688
|
+
if (entry.type === 'response_item' && entry.payload?.type === 'custom_tool_call_output') {
|
|
3689
|
+
messages.push({
|
|
3690
|
+
type: 'tool_result',
|
|
3691
|
+
timestamp: entry.timestamp,
|
|
3692
|
+
toolCallId: entry.payload.call_id,
|
|
3693
|
+
output: entry.payload.output || ''
|
|
3694
|
+
});
|
|
3695
|
+
}
|
|
3696
|
+
|
|
3697
|
+
} catch (parseError) {
|
|
3698
|
+
// Skip malformed lines
|
|
3699
|
+
}
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
// Sort by timestamp
|
|
3704
|
+
messages.sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
|
|
3705
|
+
|
|
3706
|
+
const total = messages.length;
|
|
3707
|
+
|
|
3708
|
+
// Apply pagination if limit is specified
|
|
3709
|
+
if (limit !== null) {
|
|
3710
|
+
const startIndex = Math.max(0, total - offset - limit);
|
|
3711
|
+
const endIndex = total - offset;
|
|
3712
|
+
const paginatedMessages = messages.slice(startIndex, endIndex);
|
|
3713
|
+
const hasMore = startIndex > 0;
|
|
3714
|
+
|
|
3715
|
+
return {
|
|
3716
|
+
messages: paginatedMessages,
|
|
3717
|
+
total,
|
|
3718
|
+
hasMore,
|
|
3719
|
+
offset,
|
|
3720
|
+
limit,
|
|
3721
|
+
tokenUsage
|
|
3722
|
+
};
|
|
3723
|
+
}
|
|
3724
|
+
|
|
3725
|
+
return { messages, tokenUsage };
|
|
3726
|
+
|
|
3727
|
+
} catch (error) {
|
|
3728
|
+
console.error(`Error reading Codex session messages for ${sessionId}:`, error);
|
|
3729
|
+
return { messages: [], total: 0, hasMore: false };
|
|
3730
|
+
}
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
async function deleteCodexSession(sessionId) {
|
|
3734
|
+
try {
|
|
3735
|
+
const { sessionDb } = await import('./database/db.js');
|
|
3736
|
+
const codexSessionsDir = path.join(os.homedir(), '.codex', 'sessions');
|
|
3737
|
+
const indexedSession = sessionDb.getSessionById(sessionId);
|
|
3738
|
+
|
|
3739
|
+
const findJsonlFiles = async (dir) => {
|
|
3740
|
+
const files = [];
|
|
3741
|
+
try {
|
|
3742
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
3743
|
+
for (const entry of entries) {
|
|
3744
|
+
const fullPath = path.join(dir, entry.name);
|
|
3745
|
+
if (entry.isDirectory()) {
|
|
3746
|
+
files.push(...await findJsonlFiles(fullPath));
|
|
3747
|
+
} else if (entry.name.endsWith('.jsonl')) {
|
|
3748
|
+
files.push(fullPath);
|
|
3749
|
+
}
|
|
3750
|
+
}
|
|
3751
|
+
} catch (error) {}
|
|
3752
|
+
return files;
|
|
3753
|
+
};
|
|
3754
|
+
|
|
3755
|
+
const jsonlFiles = await findJsonlFiles(codexSessionsDir);
|
|
3756
|
+
let deletedFile = false;
|
|
3757
|
+
|
|
3758
|
+
for (const filePath of jsonlFiles) {
|
|
3759
|
+
const sessionData = await parseCodexSessionFile(filePath);
|
|
3760
|
+
if (sessionData && sessionData.id === sessionId) {
|
|
3761
|
+
await fs.unlink(filePath);
|
|
3762
|
+
deletedFile = true;
|
|
3763
|
+
break;
|
|
3764
|
+
}
|
|
3765
|
+
}
|
|
3766
|
+
|
|
3767
|
+
const deletedIndex =
|
|
3768
|
+
indexedSession?.provider === 'codex' || deletedFile;
|
|
3769
|
+
|
|
3770
|
+
if (deletedIndex) {
|
|
3771
|
+
sessionDb.deleteSession(sessionId);
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3774
|
+
if (deletedFile || deletedIndex) {
|
|
3775
|
+
return true;
|
|
3776
|
+
}
|
|
3777
|
+
|
|
3778
|
+
throw new Error(`Codex session file not found for session ${sessionId}`);
|
|
3779
|
+
} catch (error) {
|
|
3780
|
+
console.error(`Error deleting Codex session ${sessionId}:`, error);
|
|
3781
|
+
throw error;
|
|
3782
|
+
}
|
|
3783
|
+
}
|
|
3784
|
+
|
|
3785
|
+
// Get workspace root from project config
|
|
3786
|
+
async function getWorkspaceRootFromConfig() {
|
|
3787
|
+
const config = await loadProjectConfig();
|
|
3788
|
+
const resolvedRoot = await resolveConfiguredWorkspacesRoot(config._workspacesRoot || null);
|
|
3789
|
+
|
|
3790
|
+
if (resolvedRoot && config._workspacesRoot !== resolvedRoot) {
|
|
3791
|
+
await mutateProjectConfig((nextConfig) => {
|
|
3792
|
+
nextConfig._workspacesRoot = resolvedRoot;
|
|
3793
|
+
});
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
return resolvedRoot || null;
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3799
|
+
// Save workspace root to project config
|
|
3800
|
+
async function setWorkspaceRootInConfig(workspacesRoot) {
|
|
3801
|
+
await mutateProjectConfig((config) => {
|
|
3802
|
+
if (workspacesRoot) {
|
|
3803
|
+
config._workspacesRoot = workspacesRoot;
|
|
3804
|
+
} else {
|
|
3805
|
+
delete config._workspacesRoot;
|
|
3806
|
+
}
|
|
3807
|
+
});
|
|
3808
|
+
}
|
|
3809
|
+
|
|
3810
|
+
// Rename a session (Claude, Gemini, or Cursor)
|
|
3811
|
+
async function renameSession(projectName, sessionId, newSummary, provider = 'claude', userId = null) {
|
|
3812
|
+
if (!newSummary || newSummary.trim() === '') {
|
|
3813
|
+
throw new Error('New session name cannot be empty');
|
|
3814
|
+
}
|
|
3815
|
+
|
|
3816
|
+
const trimmedSummary = newSummary.trim();
|
|
3817
|
+
const { sessionDb, projectDb } = await import('./database/db.js');
|
|
3818
|
+
|
|
3819
|
+
// Basic security: if project is in DB, check if it belongs to this user
|
|
3820
|
+
const project = projectDb.getProjectById(projectName);
|
|
3821
|
+
if (project && userId && project.user_id && project.user_id !== userId) {
|
|
3822
|
+
throw new Error('You do not have permission to modify sessions in this project');
|
|
3823
|
+
}
|
|
3824
|
+
|
|
3825
|
+
// 1. Handle Gemini sessions
|
|
3826
|
+
if (provider === 'gemini') {
|
|
3827
|
+
const geminiSessionFile = path.join(os.homedir(), '.gemini', 'sessions', `${sessionId}.jsonl`);
|
|
3828
|
+
try {
|
|
3829
|
+
await fs.access(geminiSessionFile);
|
|
3830
|
+
// For Gemini, we append a title/summary entry to the end of the JSONL file (compatibility)
|
|
3831
|
+
const summaryEntry = {
|
|
3832
|
+
type: 'summary',
|
|
3833
|
+
summary: trimmedSummary,
|
|
3834
|
+
title: trimmedSummary,
|
|
3835
|
+
timestamp: new Date().toISOString()
|
|
3836
|
+
};
|
|
3837
|
+
await fs.appendFile(geminiSessionFile, JSON.stringify(summaryEntry) + '\n');
|
|
3838
|
+
|
|
3839
|
+
// Also update Dr. Claw's own index (source of truth)
|
|
3840
|
+
sessionDb.updateSessionName(sessionId, trimmedSummary);
|
|
3841
|
+
|
|
3842
|
+
console.log(`[Gemini] Renamed session ${sessionId} to "${trimmedSummary}"`);
|
|
3843
|
+
return true;
|
|
3844
|
+
} catch (e) {
|
|
3845
|
+
console.error(`[Gemini] Failed to rename session ${sessionId}:`, e.message);
|
|
3846
|
+
throw new Error(`Failed to rename Gemini session: ${e.message}`);
|
|
3847
|
+
}
|
|
3848
|
+
}
|
|
3849
|
+
// 2. Handle Cursor sessions (SQLite)
|
|
3850
|
+
else if (provider === 'cursor') {
|
|
3851
|
+
const config = await loadProjectConfig();
|
|
3852
|
+
const projectPath = config[projectName]?.path || config[projectName]?.originalPath || await extractProjectDirectory(projectName);
|
|
3853
|
+
|
|
3854
|
+
if (!projectPath) {
|
|
3855
|
+
throw new Error(`Could not determine project path for ${projectName}`);
|
|
3856
|
+
}
|
|
3857
|
+
|
|
3858
|
+
const cwdId = crypto.createHash('md5').update(projectPath).digest('hex');
|
|
3859
|
+
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
|
|
3860
|
+
|
|
3861
|
+
try {
|
|
3862
|
+
await fs.access(storeDbPath);
|
|
3863
|
+
const db = await open({
|
|
3864
|
+
filename: storeDbPath,
|
|
3865
|
+
driver: sqlite3.Database
|
|
3866
|
+
});
|
|
3867
|
+
|
|
3868
|
+
// Update both title and sessionTitle keys in the meta table
|
|
3869
|
+
await db.run("UPDATE meta SET value = ? WHERE key = 'title' OR key = 'sessionTitle'", [trimmedSummary]);
|
|
3870
|
+
await db.close();
|
|
3871
|
+
|
|
3872
|
+
// Update Dr. Claw's own index
|
|
3873
|
+
sessionDb.updateSessionName(sessionId, trimmedSummary);
|
|
3874
|
+
|
|
3875
|
+
console.log(`[Cursor] Renamed session ${sessionId} to "${trimmedSummary}"`);
|
|
3876
|
+
return true;
|
|
3877
|
+
} catch (e) {
|
|
3878
|
+
console.error(`[Cursor] Failed to rename session ${sessionId}:`, e.message);
|
|
3879
|
+
throw new Error(`Failed to rename Cursor session: ${e.message}`);
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3882
|
+
// 3. Handle Claude sessions (JSONL)
|
|
3883
|
+
else {
|
|
3884
|
+
const projectDir = path.join(os.homedir(), '.claude', 'projects', projectName);
|
|
3885
|
+
|
|
3886
|
+
try {
|
|
3887
|
+
// Check if project directory exists first
|
|
3888
|
+
try {
|
|
3889
|
+
await fs.access(projectDir);
|
|
3890
|
+
} catch (e) {
|
|
3891
|
+
console.error(`[Claude] Project directory not found: ${projectDir}`);
|
|
3892
|
+
throw new Error(`Claude project directory not found: ${projectName}`);
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3895
|
+
const files = await fs.readdir(projectDir);
|
|
3896
|
+
const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
|
|
3897
|
+
|
|
3898
|
+
if (jsonlFiles.length === 0) {
|
|
3899
|
+
throw new Error('No session files found for this project');
|
|
3900
|
+
}
|
|
3901
|
+
|
|
3902
|
+
// Check all JSONL files to find which one contains the session
|
|
3903
|
+
for (const file of jsonlFiles) {
|
|
3904
|
+
const jsonlFile = path.join(projectDir, file);
|
|
3905
|
+
const content = await fs.readFile(jsonlFile, 'utf8');
|
|
3906
|
+
const lines = content.split('\n').filter(line => line.trim());
|
|
3907
|
+
|
|
3908
|
+
const hasSession = lines.some(line => {
|
|
3909
|
+
try {
|
|
3910
|
+
const data = JSON.parse(line);
|
|
3911
|
+
return data.sessionId === sessionId;
|
|
3912
|
+
} catch {
|
|
3913
|
+
return false;
|
|
3914
|
+
}
|
|
3915
|
+
});
|
|
3916
|
+
|
|
3917
|
+
if (hasSession) {
|
|
3918
|
+
// Append a new summary record for this sessionId
|
|
3919
|
+
const summaryEntry = {
|
|
3920
|
+
type: 'summary',
|
|
3921
|
+
sessionId: sessionId,
|
|
3922
|
+
summary: trimmedSummary,
|
|
3923
|
+
timestamp: new Date().toISOString()
|
|
3924
|
+
};
|
|
3925
|
+
await fs.appendFile(jsonlFile, JSON.stringify(summaryEntry) + '\n');
|
|
3926
|
+
|
|
3927
|
+
// Update Dr. Claw's own index
|
|
3928
|
+
sessionDb.updateSessionName(sessionId, trimmedSummary);
|
|
3929
|
+
|
|
3930
|
+
console.log(`[Claude] Renamed session ${sessionId} to "${trimmedSummary}"`);
|
|
3931
|
+
return true;
|
|
3932
|
+
}
|
|
3933
|
+
}
|
|
3934
|
+
|
|
3935
|
+
throw new Error(`Session ${sessionId} not found in any files`);
|
|
3936
|
+
} catch (error) {
|
|
3937
|
+
console.error(`Error renaming session ${sessionId} in project ${projectName}:`, error);
|
|
3938
|
+
throw error;
|
|
3939
|
+
}
|
|
3940
|
+
}
|
|
3941
|
+
}
|
|
3942
|
+
|
|
3943
|
+
export {
|
|
3944
|
+
getProjects,
|
|
3945
|
+
getTrashedProjects,
|
|
3946
|
+
getSessions,
|
|
3947
|
+
getSessionMessages,
|
|
3948
|
+
parseJsonlSessions,
|
|
3949
|
+
renameProject,
|
|
3950
|
+
renameSession,
|
|
3951
|
+
deleteSession,
|
|
3952
|
+
isProjectEmpty,
|
|
3953
|
+
deleteProject,
|
|
3954
|
+
restoreProject,
|
|
3955
|
+
deleteTrashedProject,
|
|
3956
|
+
addProjectManually,
|
|
3957
|
+
loadProjectConfig,
|
|
3958
|
+
saveProjectConfig,
|
|
3959
|
+
extractProjectDirectory,
|
|
3960
|
+
clearProjectDirectoryCache,
|
|
3961
|
+
getCodexSessions,
|
|
3962
|
+
getGeminiSessions,
|
|
3963
|
+
getCodexSessionMessages,
|
|
3964
|
+
deleteCodexSession,
|
|
3965
|
+
reconcileClaudeSessionIndex,
|
|
3966
|
+
reconcileCodexSessionIndex,
|
|
3967
|
+
reconcileGeminiSessionIndex,
|
|
3968
|
+
reconcileOpenRouterSessionIndex,
|
|
3969
|
+
reconcileLocalGPUSessionIndex,
|
|
3970
|
+
ensureProjectSkillLinks,
|
|
3971
|
+
getWorkspaceRootFromConfig,
|
|
3972
|
+
setWorkspaceRootInConfig
|
|
3973
|
+
};
|