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.
Files changed (287) hide show
  1. package/LICENSE +1380 -0
  2. package/NOTICE +17 -0
  3. package/README.md +875 -0
  4. package/README.zh-CN.md +857 -0
  5. package/dist/api-docs.html +878 -0
  6. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  7. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  8. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  14. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  20. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  21. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  22. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  23. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  24. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  25. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  26. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  28. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  29. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  30. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  31. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  32. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  33. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  34. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  35. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  36. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  37. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  38. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  48. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  49. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  50. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  51. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  52. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  53. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  54. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  55. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  56. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  57. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  58. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  59. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  60. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  61. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  62. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  63. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  64. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  65. package/dist/assets/_basePickBy-C0n1uo4u.js +1 -0
  66. package/dist/assets/_baseUniq-BRNmhFkc.js +1 -0
  67. package/dist/assets/arc-DOMBmC_A.js +1 -0
  68. package/dist/assets/architectureDiagram-VXUJARFQ-CtMWglbD.js +36 -0
  69. package/dist/assets/blockDiagram-VD42YOAC-DK_gTkJy.js +122 -0
  70. package/dist/assets/c4Diagram-YG6GDRKO-DrDAvsn7.js +10 -0
  71. package/dist/assets/channel-BFOE7OyC.js +1 -0
  72. package/dist/assets/chunk-4BX2VUAB-CGbhdZcN.js +1 -0
  73. package/dist/assets/chunk-55IACEB6-sGeFSxen.js +1 -0
  74. package/dist/assets/chunk-B4BG7PRW-RxgHAU_P.js +165 -0
  75. package/dist/assets/chunk-DI55MBZ5-CvHUXZrx.js +220 -0
  76. package/dist/assets/chunk-FMBD7UC4-Sn7cXVNU.js +15 -0
  77. package/dist/assets/chunk-QN33PNHL-bDKOHppN.js +1 -0
  78. package/dist/assets/chunk-QZHKN3VN-B9kN5LGm.js +1 -0
  79. package/dist/assets/chunk-TZMSLE5B-CCBe6len.js +1 -0
  80. package/dist/assets/classDiagram-2ON5EDUG-DQoMNYlY.js +1 -0
  81. package/dist/assets/classDiagram-v2-WZHVMYZB-DQoMNYlY.js +1 -0
  82. package/dist/assets/clone-BulQSblH.js +1 -0
  83. package/dist/assets/cose-bilkent-S5V4N54A-r6sLiHBW.js +1 -0
  84. package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
  85. package/dist/assets/dagre-6UL2VRFP-AbsaR1Oa.js +4 -0
  86. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  87. package/dist/assets/diagram-PSM6KHXK-DjCvdUyy.js +24 -0
  88. package/dist/assets/diagram-QEK2KX5R-CqiocXkH.js +43 -0
  89. package/dist/assets/diagram-S2PKOQOG-D5ALeoII.js +24 -0
  90. package/dist/assets/erDiagram-Q2GNP2WA-C1AeOsiz.js +60 -0
  91. package/dist/assets/flowDiagram-NV44I4VS-C98ve_DO.js +162 -0
  92. package/dist/assets/ganttDiagram-JELNMOA3-3zdLvcxD.js +267 -0
  93. package/dist/assets/gitGraphDiagram-V2S2FVAM-nDuDMsHh.js +65 -0
  94. package/dist/assets/graph-Blm-emkR.js +1 -0
  95. package/dist/assets/index-B8Yutsnm.css +1 -0
  96. package/dist/assets/index-Cf6gyhc3.js +654 -0
  97. package/dist/assets/infoDiagram-HS3SLOUP-CmzE3q0R.js +2 -0
  98. package/dist/assets/init-Gi6I4Gst.js +1 -0
  99. package/dist/assets/journeyDiagram-XKPGCS4Q-CYaW7LrP.js +139 -0
  100. package/dist/assets/kanban-definition-3W4ZIXB7-mkmW7p0j.js +89 -0
  101. package/dist/assets/layout-Ct2kFIId.js +1 -0
  102. package/dist/assets/linear-CKG9EwIJ.js +1 -0
  103. package/dist/assets/mermaid.core-CaKcUnnd.js +250 -0
  104. package/dist/assets/mindmap-definition-VGOIOE7T-Bct-pYin.js +68 -0
  105. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  106. package/dist/assets/pieDiagram-ADFJNKIX-C8wFD4Jr.js +30 -0
  107. package/dist/assets/quadrantDiagram-AYHSOK5B-CuiE_f46.js +7 -0
  108. package/dist/assets/requirementDiagram-UZGBJVZJ-C4v1CqU8.js +64 -0
  109. package/dist/assets/sankeyDiagram-TZEHDZUN-keAqqs-Y.js +10 -0
  110. package/dist/assets/sequenceDiagram-WL72ISMW-ClNbvKTB.js +145 -0
  111. package/dist/assets/stateDiagram-FKZM4ZOC-D8We-2vq.js +1 -0
  112. package/dist/assets/stateDiagram-v2-4FDKWEC3-BUURfjrE.js +1 -0
  113. package/dist/assets/timeline-definition-IT6M3QCI-DsvEMT5T.js +61 -0
  114. package/dist/assets/treemap-GDKQZRPO-TJrzN7Bj.js +162 -0
  115. package/dist/assets/vendor-codemirror-ansM-eig.js +33 -0
  116. package/dist/assets/vendor-react-BWF6YQQ3.js +8 -0
  117. package/dist/assets/vendor-xterm-DfcmCpbH.js +66 -0
  118. package/dist/assets/xychartDiagram-PRI3JC2R-BdATmY5F.js +7 -0
  119. package/dist/clear-cache.html +85 -0
  120. package/dist/convert-icons.md +53 -0
  121. package/dist/dr-claw.png +0 -0
  122. package/dist/favicon.png +0 -0
  123. package/dist/generate-icons.js +49 -0
  124. package/dist/icons/claude-ai-icon.svg +1 -0
  125. package/dist/icons/codex-white.svg +3 -0
  126. package/dist/icons/codex.svg +3 -0
  127. package/dist/icons/cursor-white.svg +12 -0
  128. package/dist/icons/cursor.svg +1 -0
  129. package/dist/icons/file.svg +16997 -0
  130. package/dist/icons/generate-icons.md +19 -0
  131. package/dist/icons/icon-128x128.png +0 -0
  132. package/dist/icons/icon-144x144.png +0 -0
  133. package/dist/icons/icon-152x152.png +0 -0
  134. package/dist/icons/icon-192x192.png +0 -0
  135. package/dist/icons/icon-384x384.png +0 -0
  136. package/dist/icons/icon-512x512.png +0 -0
  137. package/dist/icons/icon-72x72.png +0 -0
  138. package/dist/icons/icon-96x96.png +0 -0
  139. package/dist/icons/news/arxiv.svg +1 -0
  140. package/dist/icons/news/huggingface.svg +8 -0
  141. package/dist/icons/news/x-black.png +0 -0
  142. package/dist/icons/news/x-white.png +0 -0
  143. package/dist/icons/news/xiaohongshu.png +0 -0
  144. package/dist/index.html +51 -0
  145. package/dist/leveraged-cognition.png +0 -0
  146. package/dist/manifest.json +61 -0
  147. package/dist/screenshots/chat.png +0 -0
  148. package/dist/screenshots/new_claw.png +0 -0
  149. package/dist/screenshots/news_dashboard.png +0 -0
  150. package/dist/screenshots/project_dashboard.png +0 -0
  151. package/dist/screenshots/research_lab.png +0 -0
  152. package/dist/screenshots/skill_library.png +0 -0
  153. package/dist/screenshots/task_list.png +0 -0
  154. package/dist/screenshots/terminal_example.png +0 -0
  155. package/dist/screenshots/terminal_example1.png +0 -0
  156. package/dist/sw.js +58 -0
  157. package/dist/wechat-group-qr.jpg +0 -0
  158. package/package.json +133 -0
  159. package/public/api-docs.html +878 -0
  160. package/public/clear-cache.html +85 -0
  161. package/public/convert-icons.md +53 -0
  162. package/public/dr-claw.png +0 -0
  163. package/public/favicon.png +0 -0
  164. package/public/generate-icons.js +49 -0
  165. package/public/icons/claude-ai-icon.svg +1 -0
  166. package/public/icons/codex-white.svg +3 -0
  167. package/public/icons/codex.svg +3 -0
  168. package/public/icons/cursor-white.svg +12 -0
  169. package/public/icons/cursor.svg +1 -0
  170. package/public/icons/file.svg +16997 -0
  171. package/public/icons/generate-icons.md +19 -0
  172. package/public/icons/icon-128x128.png +0 -0
  173. package/public/icons/icon-144x144.png +0 -0
  174. package/public/icons/icon-152x152.png +0 -0
  175. package/public/icons/icon-192x192.png +0 -0
  176. package/public/icons/icon-384x384.png +0 -0
  177. package/public/icons/icon-512x512.png +0 -0
  178. package/public/icons/icon-72x72.png +0 -0
  179. package/public/icons/icon-96x96.png +0 -0
  180. package/public/icons/news/arxiv.svg +1 -0
  181. package/public/icons/news/huggingface.svg +8 -0
  182. package/public/icons/news/x-black.png +0 -0
  183. package/public/icons/news/x-white.png +0 -0
  184. package/public/icons/news/xiaohongshu.png +0 -0
  185. package/public/leveraged-cognition.png +0 -0
  186. package/public/manifest.json +61 -0
  187. package/public/screenshots/chat.png +0 -0
  188. package/public/screenshots/new_claw.png +0 -0
  189. package/public/screenshots/news_dashboard.png +0 -0
  190. package/public/screenshots/project_dashboard.png +0 -0
  191. package/public/screenshots/research_lab.png +0 -0
  192. package/public/screenshots/skill_library.png +0 -0
  193. package/public/screenshots/task_list.png +0 -0
  194. package/public/screenshots/terminal_example.png +0 -0
  195. package/public/screenshots/terminal_example1.png +0 -0
  196. package/public/sw.js +58 -0
  197. package/public/wechat-group-qr.jpg +0 -0
  198. package/scripts/export-skills-catalog-v2.mjs +746 -0
  199. package/scripts/fix-node-pty.js +45 -0
  200. package/scripts/gemini_image_core.py +470 -0
  201. package/scripts/install-python-tools.js +64 -0
  202. package/server/AGENTS.md +73 -0
  203. package/server/__tests__/gemini-legacy-thought.test.mjs +88 -0
  204. package/server/__tests__/runtimePorts.test.mjs +115 -0
  205. package/server/__tests__/session-delete.test.mjs +166 -0
  206. package/server/bin/compute-node +70 -0
  207. package/server/claude-sdk.js +799 -0
  208. package/server/cli-chat.js +488 -0
  209. package/server/cli.js +354 -0
  210. package/server/compute-node.js +439 -0
  211. package/server/constants/config.js +5 -0
  212. package/server/cursor-cli.js +340 -0
  213. package/server/database/auth.db +0 -0
  214. package/server/database/db.js +1852 -0
  215. package/server/database/init.sql +194 -0
  216. package/server/gemini-cli.js +1352 -0
  217. package/server/index.js +3154 -0
  218. package/server/load-env.js +71 -0
  219. package/server/local-gpu.js +860 -0
  220. package/server/middleware/auth.js +100 -0
  221. package/server/openai-codex.js +724 -0
  222. package/server/openrouter.js +890 -0
  223. package/server/project-token-usage.js +407 -0
  224. package/server/projects.js +3973 -0
  225. package/server/routes/agent.js +1272 -0
  226. package/server/routes/auth.js +128 -0
  227. package/server/routes/auto-research.js +558 -0
  228. package/server/routes/cli-auth.js +958 -0
  229. package/server/routes/codex.js +344 -0
  230. package/server/routes/commands.js +601 -0
  231. package/server/routes/compute.js +358 -0
  232. package/server/routes/cursor.js +795 -0
  233. package/server/routes/git.js +1348 -0
  234. package/server/routes/mcp-utils.js +48 -0
  235. package/server/routes/mcp.js +618 -0
  236. package/server/routes/news.js +711 -0
  237. package/server/routes/projects.js +633 -0
  238. package/server/routes/references.js +338 -0
  239. package/server/routes/settings.js +285 -0
  240. package/server/routes/skills.js +914 -0
  241. package/server/routes/taskmaster.js +2566 -0
  242. package/server/routes/telemetry.js +89 -0
  243. package/server/routes/user.js +155 -0
  244. package/server/scripts/fix_session_names.js +57 -0
  245. package/server/scripts/research-news/common_words.py +19 -0
  246. package/server/scripts/research-news/link_keywords.py +298 -0
  247. package/server/scripts/research-news/scan_existing_notes.py +266 -0
  248. package/server/scripts/research-news/scoring_utils.py +227 -0
  249. package/server/scripts/research-news/search_arxiv.py +842 -0
  250. package/server/scripts/research-news/search_huggingface.py +438 -0
  251. package/server/scripts/research-news/search_x.py +490 -0
  252. package/server/scripts/research-news/search_xiaohongshu.py +610 -0
  253. package/server/taskmaster-templates/README.md +150 -0
  254. package/server/taskmaster-templates/README.zh-CN.md +150 -0
  255. package/server/taskmaster-templates/ai-research-dataset.json +230 -0
  256. package/server/taskmaster-templates/ai-research-method-model.json +230 -0
  257. package/server/taskmaster-templates/ai-research-position-paper.json +230 -0
  258. package/server/telemetry.js +121 -0
  259. package/server/templates/AGENTS.md +103 -0
  260. package/server/templates/CLAUDE.md +104 -0
  261. package/server/templates/GEMINI.md +103 -0
  262. package/server/templates/cursor-project.md +108 -0
  263. package/server/templates/index.js +37 -0
  264. package/server/utils/__tests__/sessionTokenUsage.test.js +82 -0
  265. package/server/utils/auto-research-mailer.js +57 -0
  266. package/server/utils/cliResolution.js +170 -0
  267. package/server/utils/commandParser.js +303 -0
  268. package/server/utils/cursorCommand.js +61 -0
  269. package/server/utils/geminiApiKey.js +28 -0
  270. package/server/utils/gitConfig.js +24 -0
  271. package/server/utils/imageAttachmentFiles.js +35 -0
  272. package/server/utils/mcp-detector.js +198 -0
  273. package/server/utils/parsers/bibtex-parser.js +207 -0
  274. package/server/utils/permissions.js +100 -0
  275. package/server/utils/runtimePorts.js +212 -0
  276. package/server/utils/sessionFormatting.js +68 -0
  277. package/server/utils/sessionIndex.js +100 -0
  278. package/server/utils/sessionMode.js +95 -0
  279. package/server/utils/sessionTokenUsage.js +105 -0
  280. package/server/utils/taskmaster-websocket.js +129 -0
  281. package/server/utils/zotero-client.js +136 -0
  282. package/shared/errorClassifier.js +102 -0
  283. package/shared/geminiThinkingSupport.d.ts +22 -0
  284. package/shared/geminiThinkingSupport.js +96 -0
  285. package/shared/geminiThoughtParser.d.ts +8 -0
  286. package/shared/geminiThoughtParser.js +30 -0
  287. 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
+ };