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,1852 @@
1
+ import Database from 'better-sqlite3';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import crypto from 'crypto';
5
+ import { fileURLToPath } from 'url';
6
+ import { dirname } from 'path';
7
+ import { stripInternalContextPrefix } from '../utils/sessionFormatting.js';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+
12
+ // ANSI color codes for terminal output
13
+ const colors = {
14
+ reset: '\x1b[0m',
15
+ bright: '\x1b[1m',
16
+ cyan: '\x1b[36m',
17
+ dim: '\x1b[2m',
18
+ };
19
+
20
+ const c = {
21
+ info: (text) => `${colors.cyan}${text}${colors.reset}`,
22
+ bright: (text) => `${colors.bright}${text}${colors.reset}`,
23
+ dim: (text) => `${colors.dim}${text}${colors.reset}`,
24
+ };
25
+
26
+ const DEFAULT_STAGE_TAGS = [
27
+ { tagKey: 'survey', label: 'Survey', color: 'sky', sortOrder: 10 },
28
+ { tagKey: 'ideation', label: 'Ideation', color: 'amber', sortOrder: 20 },
29
+ { tagKey: 'experiment', label: 'Experiment', color: 'cyan', sortOrder: 30 },
30
+ { tagKey: 'publication', label: 'Publication', color: 'purple', sortOrder: 40 },
31
+ { tagKey: 'promotion', label: 'Promotion', color: 'pink', sortOrder: 50 },
32
+ ];
33
+ const STAGE_TAG_DECISIONS_KEY = 'stageTagDecisions';
34
+
35
+ // Use DATABASE_PATH environment variable if set, otherwise use default location
36
+ const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'auth.db');
37
+ const INIT_SQL_PATH = path.join(__dirname, 'init.sql');
38
+
39
+ // Ensure database directory exists if custom path is provided
40
+ if (process.env.DATABASE_PATH) {
41
+ const dbDir = path.dirname(DB_PATH);
42
+ try {
43
+ if (!fs.existsSync(dbDir)) {
44
+ fs.mkdirSync(dbDir, { recursive: true });
45
+ console.log(`Created database directory: ${dbDir}`);
46
+ }
47
+ } catch (error) {
48
+ console.error(`Failed to create database directory ${dbDir}:`, error.message);
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ // Migrate legacy local DB (repo install path) into the configured DB path.
54
+ const LEGACY_DB_PATH = path.join(__dirname, 'auth.db');
55
+ if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGACY_DB_PATH)) {
56
+ try {
57
+ fs.copyFileSync(LEGACY_DB_PATH, DB_PATH);
58
+ console.log(`[MIGRATION] Copied database from ${LEGACY_DB_PATH} to ${DB_PATH}`);
59
+ for (const suffix of ['-wal', '-shm']) {
60
+ if (fs.existsSync(LEGACY_DB_PATH + suffix)) {
61
+ fs.copyFileSync(LEGACY_DB_PATH + suffix, DB_PATH + suffix);
62
+ }
63
+ }
64
+ } catch (err) {
65
+ console.warn(`[MIGRATION] Could not copy legacy database: ${err.message}`);
66
+ }
67
+ }
68
+
69
+ // Create database connection
70
+ const db = new Database(DB_PATH);
71
+ db.pragma('foreign_keys = ON');
72
+
73
+ // Show app installation path prominently
74
+ const appInstallPath = path.join(__dirname, '../..');
75
+ console.log('');
76
+ console.log(c.dim('═'.repeat(60)));
77
+ console.log(`${c.info('[INFO]')} App Installation: ${c.bright(appInstallPath)}`);
78
+ console.log(`${c.info('[INFO]')} Database: ${c.dim(path.relative(appInstallPath, DB_PATH))}`);
79
+ if (process.env.DATABASE_PATH) {
80
+ console.log(` ${c.dim('(Using custom DATABASE_PATH from environment)')}`);
81
+ }
82
+ console.log(c.dim('═'.repeat(60)));
83
+ console.log('');
84
+
85
+ const runMigrations = () => {
86
+ try {
87
+ const tableInfo = db.prepare("PRAGMA table_info(users)").all();
88
+ const columnNames = tableInfo.map(col => col.name);
89
+
90
+ if (!columnNames.includes('git_name')) {
91
+ console.log('Running migration: Adding git_name column');
92
+ db.exec('ALTER TABLE users ADD COLUMN git_name TEXT');
93
+ }
94
+
95
+ if (!columnNames.includes('git_email')) {
96
+ console.log('Running migration: Adding git_email column');
97
+ db.exec('ALTER TABLE users ADD COLUMN git_email TEXT');
98
+ }
99
+
100
+ if (!columnNames.includes('has_completed_onboarding')) {
101
+ console.log('Running migration: Adding has_completed_onboarding column');
102
+ db.exec('ALTER TABLE users ADD COLUMN has_completed_onboarding BOOLEAN DEFAULT 0');
103
+ }
104
+
105
+ if (!columnNames.includes('notification_email')) {
106
+ console.log('Running migration: Adding notification_email column');
107
+ db.exec('ALTER TABLE users ADD COLUMN notification_email TEXT');
108
+ }
109
+
110
+ // Migration: add FK from project_references.project_id → projects(id)
111
+ const prInfo = db.prepare("PRAGMA table_info(project_references)").all();
112
+ if (prInfo.length > 0) {
113
+ const fkList = db.prepare("PRAGMA foreign_key_list(project_references)").all();
114
+ const hasProjectFk = fkList.some(fk => fk.table === 'projects');
115
+ if (!hasProjectFk) {
116
+ console.log('Running migration: Recreating project_references with FK to projects');
117
+ db.exec(`
118
+ CREATE TABLE IF NOT EXISTS project_references_new (
119
+ project_id TEXT NOT NULL,
120
+ reference_id TEXT NOT NULL,
121
+ added_at DATETIME DEFAULT CURRENT_TIMESTAMP,
122
+ UNIQUE(project_id, reference_id),
123
+ FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
124
+ FOREIGN KEY (reference_id) REFERENCES references_library(id) ON DELETE CASCADE
125
+ );
126
+ INSERT OR IGNORE INTO project_references_new (project_id, reference_id, added_at)
127
+ SELECT project_id, reference_id, added_at FROM project_references;
128
+ DROP TABLE project_references;
129
+ ALTER TABLE project_references_new RENAME TO project_references;
130
+ CREATE INDEX IF NOT EXISTS idx_project_references_project ON project_references(project_id);
131
+ `);
132
+ }
133
+ }
134
+
135
+ db.exec(`
136
+ CREATE TABLE IF NOT EXISTS project_tags (
137
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
138
+ project_name TEXT NOT NULL,
139
+ tag_key TEXT NOT NULL,
140
+ tag_type TEXT NOT NULL,
141
+ label TEXT NOT NULL,
142
+ color TEXT,
143
+ sort_order INTEGER DEFAULT 0,
144
+ metadata TEXT,
145
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
146
+ UNIQUE(project_name, tag_type, tag_key)
147
+ );
148
+ CREATE INDEX IF NOT EXISTS idx_project_tags_project ON project_tags(project_name);
149
+ CREATE INDEX IF NOT EXISTS idx_project_tags_type ON project_tags(tag_type);
150
+ CREATE TABLE IF NOT EXISTS session_tag_links (
151
+ session_id TEXT NOT NULL,
152
+ tag_id INTEGER NOT NULL,
153
+ linked_by TEXT,
154
+ source TEXT,
155
+ metadata TEXT,
156
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
157
+ UNIQUE(session_id, tag_id),
158
+ FOREIGN KEY (session_id) REFERENCES session_metadata(id) ON DELETE CASCADE,
159
+ FOREIGN KEY (tag_id) REFERENCES project_tags(id) ON DELETE CASCADE
160
+ );
161
+ CREATE INDEX IF NOT EXISTS idx_session_tag_links_session ON session_tag_links(session_id);
162
+ CREATE INDEX IF NOT EXISTS idx_session_tag_links_tag ON session_tag_links(tag_id);
163
+ `);
164
+
165
+ console.log('Database migrations completed successfully');
166
+ } catch (error) {
167
+ console.error('Error running migrations:', error.message);
168
+ throw error;
169
+ }
170
+ };
171
+
172
+ // Initialize database with schema
173
+ const initializeDatabase = async () => {
174
+ try {
175
+ const initSQL = fs.readFileSync(INIT_SQL_PATH, 'utf8');
176
+ db.exec(initSQL);
177
+ console.log('Database initialized successfully');
178
+ runMigrations();
179
+ } catch (error) {
180
+ console.error('Error initializing database:', error.message);
181
+ throw error;
182
+ }
183
+ };
184
+
185
+ // User database operations
186
+ const userDb = {
187
+ // Check if any users exist
188
+ hasUsers: () => {
189
+ try {
190
+ const row = db.prepare('SELECT COUNT(*) as count FROM users').get();
191
+ return row.count > 0;
192
+ } catch (err) {
193
+ throw err;
194
+ }
195
+ },
196
+
197
+ // Create a new user
198
+ createUser: (username, passwordHash, notificationEmail = null) => {
199
+ try {
200
+ const stmt = db.prepare('INSERT INTO users (username, password_hash, notification_email) VALUES (?, ?, ?)');
201
+ const result = stmt.run(username, passwordHash, notificationEmail);
202
+ return { id: result.lastInsertRowid, username, notification_email: notificationEmail };
203
+ } catch (err) {
204
+ throw err;
205
+ }
206
+ },
207
+
208
+ // Get user by username
209
+ getUserByUsername: (username) => {
210
+ try {
211
+ const row = db.prepare('SELECT * FROM users WHERE username = ? AND is_active = 1').get(username);
212
+ return row;
213
+ } catch (err) {
214
+ throw err;
215
+ }
216
+ },
217
+
218
+ resetSingleUser: () => {
219
+ try {
220
+ db.prepare('DELETE FROM users').run();
221
+ } catch (err) {
222
+ throw err;
223
+ }
224
+ },
225
+
226
+ // Update last login time (non-fatal)
227
+ updateLastLogin: (userId) => {
228
+ try {
229
+ db.prepare('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
230
+ } catch (err) {
231
+ console.warn('Failed to update last login:', err.message);
232
+ }
233
+ },
234
+
235
+ // Get user by ID
236
+ getUserById: (userId) => {
237
+ try {
238
+ const row = db.prepare('SELECT id, username, notification_email, created_at, last_login FROM users WHERE id = ? AND is_active = 1').get(userId);
239
+ return row;
240
+ } catch (err) {
241
+ throw err;
242
+ }
243
+ },
244
+
245
+ getFirstUser: () => {
246
+ try {
247
+ const row = db.prepare('SELECT id, username, notification_email, created_at, last_login FROM users WHERE is_active = 1 LIMIT 1').get();
248
+ return row;
249
+ } catch (err) {
250
+ throw err;
251
+ }
252
+ },
253
+
254
+ updateGitConfig: (userId, gitName, gitEmail) => {
255
+ try {
256
+ const stmt = db.prepare('UPDATE users SET git_name = ?, git_email = ? WHERE id = ?');
257
+ stmt.run(gitName, gitEmail, userId);
258
+ } catch (err) {
259
+ throw err;
260
+ }
261
+ },
262
+
263
+ getGitConfig: (userId) => {
264
+ try {
265
+ const row = db.prepare('SELECT git_name, git_email FROM users WHERE id = ?').get(userId);
266
+ return row;
267
+ } catch (err) {
268
+ throw err;
269
+ }
270
+ },
271
+
272
+ completeOnboarding: (userId) => {
273
+ try {
274
+ const stmt = db.prepare('UPDATE users SET has_completed_onboarding = 1 WHERE id = ?');
275
+ stmt.run(userId);
276
+ } catch (err) {
277
+ throw err;
278
+ }
279
+ },
280
+
281
+ hasCompletedOnboarding: (userId) => {
282
+ try {
283
+ const row = db.prepare('SELECT has_completed_onboarding FROM users WHERE id = ?').get(userId);
284
+ return row?.has_completed_onboarding === 1;
285
+ } catch (err) {
286
+ throw err;
287
+ }
288
+ },
289
+
290
+ getProfile: (userId) => {
291
+ try {
292
+ return db.prepare('SELECT id, username, notification_email FROM users WHERE id = ? AND is_active = 1').get(userId);
293
+ } catch (err) {
294
+ throw err;
295
+ }
296
+ },
297
+
298
+ updateProfile: (userId, notificationEmail) => {
299
+ try {
300
+ db.prepare('UPDATE users SET notification_email = ? WHERE id = ?').run(notificationEmail, userId);
301
+ return userDb.getProfile(userId);
302
+ } catch (err) {
303
+ throw err;
304
+ }
305
+ }
306
+ };
307
+
308
+ const autoResearchDb = {
309
+ createRun: (input) => {
310
+ try {
311
+ db.prepare(`
312
+ INSERT INTO auto_research_runs (
313
+ id, user_id, project_name, project_path, provider, status, session_id,
314
+ current_task_id, completed_tasks, total_tasks, error, metadata
315
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
316
+ `).run(
317
+ input.id,
318
+ input.userId,
319
+ input.projectName,
320
+ input.projectPath,
321
+ input.provider || 'claude',
322
+ input.status || 'queued',
323
+ input.sessionId || null,
324
+ input.currentTaskId || null,
325
+ input.completedTasks || 0,
326
+ input.totalTasks || 0,
327
+ input.error || null,
328
+ input.metadata ? JSON.stringify(input.metadata) : null
329
+ );
330
+ return autoResearchDb.getRunById(input.id);
331
+ } catch (err) {
332
+ throw err;
333
+ }
334
+ },
335
+
336
+ getRunById: (runId) => {
337
+ try {
338
+ const row = db.prepare('SELECT * FROM auto_research_runs WHERE id = ?').get(runId);
339
+ return row ? {
340
+ ...row,
341
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
342
+ } : null;
343
+ } catch (err) {
344
+ throw err;
345
+ }
346
+ },
347
+
348
+ getLatestRunForProject: (userId, projectName) => {
349
+ try {
350
+ const row = db.prepare(`
351
+ SELECT * FROM auto_research_runs
352
+ WHERE user_id = ? AND project_name = ?
353
+ ORDER BY started_at DESC
354
+ LIMIT 1
355
+ `).get(userId, projectName);
356
+ return row ? {
357
+ ...row,
358
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
359
+ } : null;
360
+ } catch (err) {
361
+ throw err;
362
+ }
363
+ },
364
+
365
+ getActiveRunForProject: (userId, projectName) => {
366
+ try {
367
+ const row = db.prepare(`
368
+ SELECT * FROM auto_research_runs
369
+ WHERE user_id = ? AND project_name = ? AND status IN ('queued', 'running', 'cancelling')
370
+ ORDER BY started_at DESC
371
+ LIMIT 1
372
+ `).get(userId, projectName);
373
+ return row ? {
374
+ ...row,
375
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
376
+ } : null;
377
+ } catch (err) {
378
+ throw err;
379
+ }
380
+ },
381
+
382
+ updateRun: (runId, updates = {}) => {
383
+ try {
384
+ const existing = autoResearchDb.getRunById(runId);
385
+ if (!existing) {
386
+ return null;
387
+ }
388
+
389
+ const mergedMetadata = Object.prototype.hasOwnProperty.call(updates, 'metadata')
390
+ ? updates.metadata
391
+ : existing.metadata;
392
+
393
+ db.prepare(`
394
+ UPDATE auto_research_runs
395
+ SET
396
+ status = ?,
397
+ session_id = ?,
398
+ current_task_id = ?,
399
+ completed_tasks = ?,
400
+ total_tasks = ?,
401
+ error = ?,
402
+ metadata = ?,
403
+ finished_at = ?,
404
+ email_sent_at = ?
405
+ WHERE id = ?
406
+ `).run(
407
+ updates.status ?? existing.status,
408
+ updates.sessionId ?? existing.session_id,
409
+ updates.currentTaskId ?? existing.current_task_id,
410
+ updates.completedTasks ?? existing.completed_tasks,
411
+ updates.totalTasks ?? existing.total_tasks,
412
+ updates.error ?? existing.error,
413
+ mergedMetadata ? JSON.stringify(mergedMetadata) : null,
414
+ updates.finishedAt ?? existing.finished_at,
415
+ updates.emailSentAt ?? existing.email_sent_at,
416
+ runId
417
+ );
418
+
419
+ return autoResearchDb.getRunById(runId);
420
+ } catch (err) {
421
+ throw err;
422
+ }
423
+ },
424
+ };
425
+
426
+ const appSettingsDb = {
427
+ get: (key) => {
428
+ try {
429
+ const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key);
430
+ return row ? row.value : null;
431
+ } catch (err) {
432
+ throw err;
433
+ }
434
+ },
435
+
436
+ set: (key, value) => {
437
+ try {
438
+ db.prepare(`
439
+ INSERT INTO app_settings (key, value, updated_at)
440
+ VALUES (?, ?, CURRENT_TIMESTAMP)
441
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
442
+ `).run(key, value);
443
+ return appSettingsDb.get(key);
444
+ } catch (err) {
445
+ throw err;
446
+ }
447
+ },
448
+ };
449
+
450
+ // API Keys database operations
451
+ const apiKeysDb = {
452
+ // Generate a new API key
453
+ generateApiKey: () => {
454
+ return 'ck_' + crypto.randomBytes(32).toString('hex');
455
+ },
456
+
457
+ // Create a new API key
458
+ createApiKey: (userId, keyName) => {
459
+ try {
460
+ const apiKey = apiKeysDb.generateApiKey();
461
+ const stmt = db.prepare('INSERT INTO api_keys (user_id, key_name, api_key) VALUES (?, ?, ?)');
462
+ const result = stmt.run(userId, keyName, apiKey);
463
+ return { id: result.lastInsertRowid, keyName, apiKey };
464
+ } catch (err) {
465
+ throw err;
466
+ }
467
+ },
468
+
469
+ // Get all API keys for a user
470
+ getApiKeys: (userId) => {
471
+ try {
472
+ const rows = db.prepare('SELECT id, key_name, api_key, created_at, last_used, is_active FROM api_keys WHERE user_id = ? ORDER BY created_at DESC').all(userId);
473
+ return rows;
474
+ } catch (err) {
475
+ throw err;
476
+ }
477
+ },
478
+
479
+ // Validate API key and get user
480
+ validateApiKey: (apiKey) => {
481
+ try {
482
+ const row = db.prepare(`
483
+ SELECT u.id, u.username, ak.id as api_key_id
484
+ FROM api_keys ak
485
+ JOIN users u ON ak.user_id = u.id
486
+ WHERE ak.api_key = ? AND ak.is_active = 1 AND u.is_active = 1
487
+ `).get(apiKey);
488
+
489
+ if (row) {
490
+ // Update last_used timestamp
491
+ db.prepare('UPDATE api_keys SET last_used = CURRENT_TIMESTAMP WHERE id = ?').run(row.api_key_id);
492
+ }
493
+
494
+ return row;
495
+ } catch (err) {
496
+ throw err;
497
+ }
498
+ },
499
+
500
+ // Delete an API key
501
+ deleteApiKey: (userId, apiKeyId) => {
502
+ try {
503
+ const stmt = db.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?');
504
+ const result = stmt.run(apiKeyId, userId);
505
+ return result.changes > 0;
506
+ } catch (err) {
507
+ throw err;
508
+ }
509
+ },
510
+
511
+ // Toggle API key active status
512
+ toggleApiKey: (userId, apiKeyId, isActive) => {
513
+ try {
514
+ const stmt = db.prepare('UPDATE api_keys SET is_active = ? WHERE id = ? AND user_id = ?');
515
+ const result = stmt.run(isActive ? 1 : 0, apiKeyId, userId);
516
+ return result.changes > 0;
517
+ } catch (err) {
518
+ throw err;
519
+ }
520
+ }
521
+ };
522
+
523
+ // User credentials database operations (for GitHub tokens, GitLab tokens, etc.)
524
+ const credentialsDb = {
525
+ // Create a new credential
526
+ createCredential: (userId, credentialName, credentialType, credentialValue, description = null) => {
527
+ try {
528
+ const stmt = db.prepare('INSERT INTO user_credentials (user_id, credential_name, credential_type, credential_value, description) VALUES (?, ?, ?, ?, ?)');
529
+ const result = stmt.run(userId, credentialName, credentialType, credentialValue, description);
530
+ return { id: result.lastInsertRowid, credentialName, credentialType };
531
+ } catch (err) {
532
+ throw err;
533
+ }
534
+ },
535
+
536
+ // Get all credentials for a user, optionally filtered by type
537
+ getCredentials: (userId, credentialType = null) => {
538
+ try {
539
+ let query = 'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ?';
540
+ const params = [userId];
541
+
542
+ if (credentialType) {
543
+ query += ' AND credential_type = ?';
544
+ params.push(credentialType);
545
+ }
546
+
547
+ query += ' ORDER BY created_at DESC';
548
+
549
+ const rows = db.prepare(query).all(...params);
550
+ return rows;
551
+ } catch (err) {
552
+ throw err;
553
+ }
554
+ },
555
+
556
+ // Get active credential value for a user by type (returns most recent active)
557
+ getActiveCredential: (userId, credentialType) => {
558
+ try {
559
+ const row = db.prepare('SELECT credential_value FROM user_credentials WHERE user_id = ? AND credential_type = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1').get(userId, credentialType);
560
+ return row?.credential_value || null;
561
+ } catch (err) {
562
+ throw err;
563
+ }
564
+ },
565
+
566
+ // Delete a credential
567
+ deleteCredential: (userId, credentialId) => {
568
+ try {
569
+ const stmt = db.prepare('DELETE FROM user_credentials WHERE id = ? AND user_id = ?');
570
+ const result = stmt.run(credentialId, userId);
571
+ return result.changes > 0;
572
+ } catch (err) {
573
+ throw err;
574
+ }
575
+ },
576
+
577
+ // Toggle credential active status
578
+ toggleCredential: (userId, credentialId, isActive) => {
579
+ try {
580
+ const stmt = db.prepare('UPDATE user_credentials SET is_active = ? WHERE id = ? AND user_id = ?');
581
+ const result = stmt.run(isActive ? 1 : 0, credentialId, userId);
582
+ return result.changes > 0;
583
+ } catch (err) {
584
+ throw err;
585
+ }
586
+ }
587
+ };
588
+
589
+ // Backward compatibility - keep old names pointing to new system
590
+ const githubTokensDb = {
591
+ createGithubToken: (userId, tokenName, githubToken, description = null) => {
592
+ return credentialsDb.createCredential(userId, tokenName, 'github_token', githubToken, description);
593
+ },
594
+ getGithubTokens: (userId) => {
595
+ return credentialsDb.getCredentials(userId, 'github_token');
596
+ },
597
+ getActiveGithubToken: (userId) => {
598
+ return credentialsDb.getActiveCredential(userId, 'github_token');
599
+ },
600
+ deleteGithubToken: (userId, tokenId) => {
601
+ return credentialsDb.deleteCredential(userId, tokenId);
602
+ },
603
+ toggleGithubToken: (userId, tokenId, isActive) => {
604
+ return credentialsDb.toggleCredential(userId, tokenId, isActive);
605
+ }
606
+ };
607
+
608
+ // Session metadata index operations
609
+ function parseSessionRow(row) {
610
+ if (!row) {
611
+ return null;
612
+ }
613
+
614
+ return {
615
+ ...row,
616
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
617
+ tags: Array.isArray(row.tags) ? row.tags : [],
618
+ };
619
+ }
620
+
621
+ function parseTagRow(row) {
622
+ if (!row) {
623
+ return null;
624
+ }
625
+
626
+ return {
627
+ id: row.id,
628
+ projectName: row.project_name,
629
+ tagKey: row.tag_key,
630
+ tagType: row.tag_type,
631
+ label: row.label,
632
+ color: row.color ?? null,
633
+ sortOrder: row.sort_order,
634
+ metadata: row.metadata ? JSON.parse(row.metadata) : null,
635
+ createdAt: row.created_at,
636
+ source: row.source ?? null,
637
+ linkedBy: row.linked_by ?? null,
638
+ linkedAt: row.linked_at ?? null,
639
+ linkMetadata: row.link_metadata ? JSON.parse(row.link_metadata) : null,
640
+ };
641
+ }
642
+
643
+ function normalizeSessionDisplayName(displayName) {
644
+ if (displayName === null || displayName === undefined) {
645
+ return null;
646
+ }
647
+
648
+ return stripInternalContextPrefix(displayName);
649
+ }
650
+
651
+ function normalizeSessionTimestamp(timestamp) {
652
+ if (!timestamp) {
653
+ return null;
654
+ }
655
+
656
+ const value = timestamp instanceof Date ? timestamp.toISOString() : String(timestamp).trim();
657
+ if (!value) {
658
+ return null;
659
+ }
660
+
661
+ const parsed = new Date(value);
662
+ return Number.isNaN(parsed.getTime()) ? value : parsed.toISOString();
663
+ }
664
+
665
+ // Returns "YYYY-MM-DD HH:MM:SS" format for SQLite created_at column convention
666
+ function normalizeSessionCreatedAt(timestamp) {
667
+ if (!timestamp) {
668
+ return null;
669
+ }
670
+
671
+ const value = timestamp instanceof Date ? timestamp.toISOString() : String(timestamp).trim();
672
+ if (!value) {
673
+ return null;
674
+ }
675
+
676
+ const parsed = new Date(value);
677
+ if (!Number.isNaN(parsed.getTime())) {
678
+ return parsed.toISOString().replace('T', ' ').slice(0, 19);
679
+ }
680
+
681
+ return value;
682
+ }
683
+
684
+ function mergeSessionMetadata(existingMetadata, incomingMetadata) {
685
+ const base = existingMetadata && typeof existingMetadata === 'object' ? existingMetadata : {};
686
+ const incoming = incomingMetadata && typeof incomingMetadata === 'object' ? incomingMetadata : {};
687
+ return {
688
+ ...base,
689
+ ...incoming,
690
+ };
691
+ }
692
+
693
+ function resolveLatestActivity(existingActivity, incomingActivity) {
694
+ const normalizedExisting = normalizeSessionTimestamp(existingActivity);
695
+ const normalizedIncoming = normalizeSessionTimestamp(incomingActivity);
696
+ if (!normalizedExisting) {
697
+ return normalizedIncoming;
698
+ }
699
+ if (!normalizedIncoming) {
700
+ return normalizedExisting;
701
+ }
702
+
703
+ const existingTime = new Date(normalizedExisting).getTime();
704
+ const incomingTime = new Date(normalizedIncoming).getTime();
705
+ if (Number.isNaN(existingTime)) {
706
+ return normalizedIncoming;
707
+ }
708
+ if (Number.isNaN(incomingTime)) {
709
+ return normalizedExisting;
710
+ }
711
+
712
+ return incomingTime >= existingTime ? normalizedIncoming : normalizedExisting;
713
+ }
714
+
715
+ function resolveMessageCount(existingCount, incomingCount) {
716
+ const normalizedExisting = Number(existingCount || 0);
717
+ const normalizedIncoming = Number(incomingCount || 0);
718
+ return Math.max(normalizedExisting, normalizedIncoming);
719
+ }
720
+
721
+ function normalizeMetadataObject(metadata) {
722
+ return metadata && typeof metadata === 'object' && !Array.isArray(metadata)
723
+ ? { ...metadata }
724
+ : {};
725
+ }
726
+
727
+ function serializeMetadata(metadata) {
728
+ const normalized = normalizeMetadataObject(metadata);
729
+ return Object.keys(normalized).length > 0 ? JSON.stringify(normalized) : null;
730
+ }
731
+
732
+ function getStageTagDecisions(metadata) {
733
+ const metadataObject = normalizeMetadataObject(metadata);
734
+ const decisions = metadataObject[STAGE_TAG_DECISIONS_KEY];
735
+ return decisions && typeof decisions === 'object' && !Array.isArray(decisions)
736
+ ? { ...decisions }
737
+ : {};
738
+ }
739
+
740
+ function applyManualStageTagDecisions(existingMetadata, projectStageTags = [], selectedTags = []) {
741
+ const metadataObject = normalizeMetadataObject(existingMetadata);
742
+ const decisions = getStageTagDecisions(metadataObject);
743
+ const selectedStageKeys = new Set(
744
+ (Array.isArray(selectedTags) ? selectedTags : [])
745
+ .filter((tag) => tag?.tagType === 'stage')
746
+ .map((tag) => tag.tagKey)
747
+ .filter(Boolean)
748
+ );
749
+ const timestamp = new Date().toISOString();
750
+
751
+ (Array.isArray(projectStageTags) ? projectStageTags : []).forEach((tag) => {
752
+ const tagKey = tag?.tagKey || tag?.tag_key;
753
+ if (!tagKey) {
754
+ return;
755
+ }
756
+
757
+ decisions[tagKey] = {
758
+ decision: selectedStageKeys.has(tagKey) ? 'selected' : 'excluded',
759
+ source: 'manual',
760
+ updatedAt: timestamp,
761
+ };
762
+ });
763
+
764
+ metadataObject[STAGE_TAG_DECISIONS_KEY] = decisions;
765
+ return metadataObject;
766
+ }
767
+
768
+ function isAutomaticStageTagBlocked(metadata, tagType, tagKey, source) {
769
+ if (tagType !== 'stage' || !tagKey || source === 'manual') {
770
+ return false;
771
+ }
772
+
773
+ const decisions = getStageTagDecisions(metadata);
774
+ const decision = decisions[tagKey];
775
+ return decision?.decision === 'excluded' && decision?.source === 'manual';
776
+ }
777
+
778
+ function hydrateSessionRowsWithTags(rows = []) {
779
+ if (!Array.isArray(rows) || rows.length === 0) {
780
+ return [];
781
+ }
782
+
783
+ const sessionIds = Array.from(new Set(rows.map((row) => row?.id).filter(Boolean)));
784
+ if (sessionIds.length === 0) {
785
+ return rows.map(parseSessionRow).filter(Boolean);
786
+ }
787
+
788
+ // SQLite default SQLITE_MAX_VARIABLE_NUMBER is 999; use 900 to leave headroom.
789
+ const chunkSize = 900;
790
+ const tagsBySessionId = new Map();
791
+
792
+ for (let index = 0; index < sessionIds.length; index += chunkSize) {
793
+ const chunk = sessionIds.slice(index, index + chunkSize);
794
+ const placeholders = chunk.map(() => '?').join(', ');
795
+ const tagRows = db.prepare(`
796
+ SELECT
797
+ stl.session_id,
798
+ pt.id,
799
+ pt.project_name,
800
+ pt.tag_key,
801
+ pt.tag_type,
802
+ pt.label,
803
+ pt.color,
804
+ pt.sort_order,
805
+ pt.metadata,
806
+ pt.created_at,
807
+ stl.linked_by,
808
+ stl.source,
809
+ stl.metadata AS link_metadata,
810
+ stl.created_at AS linked_at
811
+ FROM session_tag_links stl
812
+ JOIN project_tags pt ON pt.id = stl.tag_id
813
+ WHERE stl.session_id IN (${placeholders})
814
+ ORDER BY pt.sort_order ASC, pt.label COLLATE NOCASE ASC, pt.id ASC
815
+ `).all(...chunk);
816
+
817
+ tagRows.forEach((tagRow) => {
818
+ const parsed = parseTagRow(tagRow);
819
+ if (!parsed) {
820
+ return;
821
+ }
822
+
823
+ const existing = tagsBySessionId.get(tagRow.session_id) || [];
824
+ existing.push(parsed);
825
+ tagsBySessionId.set(tagRow.session_id, existing);
826
+ });
827
+ }
828
+
829
+ return rows.map((row) => parseSessionRow({
830
+ ...row,
831
+ tags: tagsBySessionId.get(row.id) || [],
832
+ })).filter(Boolean);
833
+ }
834
+
835
+ const sessionDb = {
836
+ // Upsert session metadata (insert if not exists, update if exists)
837
+ upsertSession: (id, projectName, provider, displayName, lastActivity, messageCount = 0, metadata = null) => {
838
+ try {
839
+ sessionDb.upsertSessionFromSource(id, projectName, provider, {
840
+ displayName,
841
+ lastActivity,
842
+ messageCount,
843
+ metadata,
844
+ });
845
+ } catch (err) {
846
+ console.error('Error upserting session metadata:', err.message);
847
+ }
848
+ },
849
+
850
+ upsertSessionPlaceholder: (id, projectName, provider, displayName = null, lastActivity = null, metadata = null) => {
851
+ try {
852
+ const existing = parseSessionRow(db.prepare('SELECT * FROM session_metadata WHERE id = ?').get(id));
853
+ const cleanedDisplayName = normalizeSessionDisplayName(displayName);
854
+ const mergedMetadata = mergeSessionMetadata(existing?.metadata, metadata);
855
+ const normalizedLastActivity = resolveLatestActivity(existing?.last_activity, lastActivity);
856
+
857
+ if (!existing) {
858
+ db.prepare(`
859
+ INSERT INTO session_metadata (id, project_name, provider, display_name, last_activity, message_count, metadata, created_at)
860
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
861
+ `).run(
862
+ id,
863
+ projectName,
864
+ provider,
865
+ cleanedDisplayName,
866
+ normalizedLastActivity,
867
+ 0,
868
+ Object.keys(mergedMetadata).length > 0 ? JSON.stringify(mergedMetadata) : null,
869
+ normalizeSessionCreatedAt(lastActivity) || normalizeSessionCreatedAt(new Date())
870
+ );
871
+ return;
872
+ }
873
+
874
+ db.prepare(`
875
+ UPDATE session_metadata
876
+ SET project_name = ?,
877
+ provider = ?,
878
+ last_activity = ?,
879
+ metadata = ?,
880
+ display_name = CASE
881
+ WHEN display_name IS NULL OR trim(display_name) = '' THEN ?
882
+ ELSE display_name
883
+ END
884
+ WHERE id = ?
885
+ `).run(
886
+ projectName,
887
+ provider,
888
+ normalizedLastActivity,
889
+ Object.keys(mergedMetadata).length > 0 ? JSON.stringify(mergedMetadata) : null,
890
+ cleanedDisplayName,
891
+ id
892
+ );
893
+ } catch (err) {
894
+ console.error('Error upserting placeholder session metadata:', err.message);
895
+ }
896
+ },
897
+
898
+ upsertSessionFromSource: (id, projectName, provider, payload = {}) => {
899
+ try {
900
+ const existing = parseSessionRow(db.prepare('SELECT * FROM session_metadata WHERE id = ?').get(id));
901
+ const incomingDisplayName = normalizeSessionDisplayName(payload.displayName);
902
+ const mergedMetadata = mergeSessionMetadata(existing?.metadata, payload.metadata);
903
+ const normalizedLastActivity = resolveLatestActivity(existing?.last_activity, payload.lastActivity);
904
+ const resolvedMessageCount = resolveMessageCount(existing?.message_count, payload.messageCount);
905
+ const createdAt =
906
+ existing?.created_at ||
907
+ normalizeSessionCreatedAt(payload.createdAt) ||
908
+ normalizeSessionCreatedAt(payload.lastActivity) ||
909
+ normalizeSessionCreatedAt(new Date());
910
+ const resolvedStarred = Number(payload.isStarred ?? existing?.is_starred ?? 0);
911
+
912
+ if (!existing) {
913
+ db.prepare(`
914
+ INSERT INTO session_metadata (id, project_name, provider, display_name, last_activity, message_count, is_starred, metadata, created_at)
915
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
916
+ `).run(
917
+ id,
918
+ projectName,
919
+ provider,
920
+ incomingDisplayName,
921
+ normalizedLastActivity,
922
+ resolvedMessageCount,
923
+ resolvedStarred,
924
+ Object.keys(mergedMetadata).length > 0 ? JSON.stringify(mergedMetadata) : null,
925
+ createdAt
926
+ );
927
+ return;
928
+ }
929
+
930
+ db.prepare(`
931
+ UPDATE session_metadata
932
+ SET project_name = ?,
933
+ provider = ?,
934
+ display_name = ?,
935
+ last_activity = ?,
936
+ message_count = ?,
937
+ is_starred = ?,
938
+ metadata = ?
939
+ WHERE id = ?
940
+ `).run(
941
+ projectName || existing.project_name,
942
+ provider || existing.provider,
943
+ incomingDisplayName || existing.display_name,
944
+ normalizedLastActivity,
945
+ resolvedMessageCount,
946
+ resolvedStarred,
947
+ Object.keys(mergedMetadata).length > 0 ? JSON.stringify(mergedMetadata) : null,
948
+ id
949
+ );
950
+ } catch (err) {
951
+ console.error('Error upserting session metadata from source:', err.message);
952
+ }
953
+ },
954
+
955
+ // Update session name ONLY (priority for manual rename)
956
+ updateSessionName: (id, displayName) => {
957
+ try {
958
+ const cleanedDisplayName = normalizeSessionDisplayName(displayName);
959
+ const stmt = db.prepare('UPDATE session_metadata SET display_name = ? WHERE id = ?');
960
+ stmt.run(cleanedDisplayName, id);
961
+ } catch (err) {
962
+ console.error('Error updating session name:', err.message);
963
+ }
964
+ },
965
+
966
+ migrateSessionId: (oldId, newId, provider = null, projectName = null) => {
967
+ try {
968
+ if (!oldId || !newId || oldId === newId) {
969
+ return;
970
+ }
971
+
972
+ const oldRow = parseSessionRow(db.prepare('SELECT * FROM session_metadata WHERE id = ?').get(oldId));
973
+ if (!oldRow) {
974
+ return;
975
+ }
976
+
977
+ const newRow = parseSessionRow(db.prepare('SELECT * FROM session_metadata WHERE id = ?').get(newId));
978
+ const mergedMetadata = mergeSessionMetadata(oldRow.metadata, newRow?.metadata);
979
+ const mergedLastActivity = resolveLatestActivity(oldRow.last_activity, newRow?.last_activity);
980
+ const mergedMessageCount = resolveMessageCount(oldRow.message_count, newRow?.message_count);
981
+ const mergedDisplayName =
982
+ normalizeSessionDisplayName(newRow?.display_name) ||
983
+ normalizeSessionDisplayName(oldRow.display_name);
984
+ const mergedCreatedAt = newRow?.created_at || oldRow.created_at || normalizeSessionCreatedAt(mergedLastActivity) || normalizeSessionCreatedAt(new Date());
985
+ const mergedStarred = Number(newRow?.is_starred || oldRow.is_starred || 0);
986
+
987
+ const migrate = db.transaction(() => {
988
+ if (!newRow) {
989
+ db.prepare(`
990
+ INSERT INTO session_metadata (id, project_name, provider, display_name, last_activity, message_count, is_starred, metadata, created_at)
991
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
992
+ `).run(
993
+ newId,
994
+ projectName || oldRow.project_name,
995
+ provider || oldRow.provider,
996
+ mergedDisplayName,
997
+ mergedLastActivity,
998
+ mergedMessageCount,
999
+ mergedStarred,
1000
+ Object.keys(mergedMetadata).length > 0 ? JSON.stringify(mergedMetadata) : null,
1001
+ mergedCreatedAt
1002
+ );
1003
+ } else {
1004
+ db.prepare(`
1005
+ UPDATE session_metadata
1006
+ SET project_name = ?,
1007
+ provider = ?,
1008
+ display_name = ?,
1009
+ last_activity = ?,
1010
+ message_count = ?,
1011
+ is_starred = ?,
1012
+ metadata = ?,
1013
+ created_at = ?
1014
+ WHERE id = ?
1015
+ `).run(
1016
+ projectName || newRow.project_name || oldRow.project_name,
1017
+ provider || newRow.provider || oldRow.provider,
1018
+ mergedDisplayName,
1019
+ mergedLastActivity,
1020
+ mergedMessageCount,
1021
+ mergedStarred,
1022
+ Object.keys(mergedMetadata).length > 0 ? JSON.stringify(mergedMetadata) : null,
1023
+ mergedCreatedAt,
1024
+ newId
1025
+ );
1026
+ }
1027
+
1028
+ db.prepare('DELETE FROM session_metadata WHERE id = ?').run(oldId);
1029
+ });
1030
+
1031
+ migrate();
1032
+ } catch (err) {
1033
+ console.error('Error migrating session metadata ID:', err.message);
1034
+ }
1035
+ },
1036
+
1037
+ // Get all metadata for sessions in a project
1038
+ getSessionsByProject: (projectName) => {
1039
+ try {
1040
+ const rows = db.prepare('SELECT * FROM session_metadata WHERE project_name = ?').all(projectName);
1041
+ return hydrateSessionRowsWithTags(rows);
1042
+ } catch (err) {
1043
+ console.error('Error getting project sessions:', err.message);
1044
+ return [];
1045
+ }
1046
+ },
1047
+
1048
+ getSessionsByProjects: (projectNames = []) => {
1049
+ try {
1050
+ if (!Array.isArray(projectNames) || projectNames.length === 0) {
1051
+ return [];
1052
+ }
1053
+
1054
+ // SQLite default SQLITE_MAX_VARIABLE_NUMBER is 999; use 900 to leave headroom.
1055
+ const chunkSize = 900;
1056
+ const allRows = [];
1057
+
1058
+ for (let index = 0; index < projectNames.length; index += chunkSize) {
1059
+ const chunk = projectNames.slice(index, index + chunkSize);
1060
+ const placeholders = chunk.map(() => '?').join(', ');
1061
+ const rows = db.prepare(
1062
+ `SELECT * FROM session_metadata WHERE project_name IN (${placeholders}) ORDER BY datetime(last_activity) DESC, datetime(created_at) DESC`
1063
+ ).all(...chunk);
1064
+ allRows.push(...rows);
1065
+ }
1066
+
1067
+ return hydrateSessionRowsWithTags(allRows);
1068
+ } catch (err) {
1069
+ console.error('Error getting sessions for projects:', err.message);
1070
+ return [];
1071
+ }
1072
+ },
1073
+
1074
+ // Get metadata for a specific session
1075
+ getSessionById: (id) => {
1076
+ try {
1077
+ return hydrateSessionRowsWithTags([
1078
+ db.prepare('SELECT * FROM session_metadata WHERE id = ?').get(id)
1079
+ ])[0] || null;
1080
+ } catch (err) {
1081
+ console.error('Error getting session metadata:', err.message);
1082
+ return null;
1083
+ }
1084
+ },
1085
+
1086
+ updateSessionMetadata: (id, updater) => {
1087
+ try {
1088
+ const row = db.prepare('SELECT metadata FROM session_metadata WHERE id = ?').get(id);
1089
+ if (!row) {
1090
+ return null;
1091
+ }
1092
+
1093
+ const currentMetadata = row.metadata ? JSON.parse(row.metadata) : null;
1094
+ const nextMetadata = typeof updater === 'function'
1095
+ ? updater(normalizeMetadataObject(currentMetadata))
1096
+ : mergeSessionMetadata(currentMetadata, updater);
1097
+
1098
+ db.prepare('UPDATE session_metadata SET metadata = ? WHERE id = ?').run(
1099
+ serializeMetadata(nextMetadata),
1100
+ id
1101
+ );
1102
+
1103
+ return sessionDb.getSessionById(id);
1104
+ } catch (err) {
1105
+ console.error('Error updating session metadata:', err.message);
1106
+ return null;
1107
+ }
1108
+ },
1109
+
1110
+ getSessionContextReview: (id) => {
1111
+ try {
1112
+ const session = sessionDb.getSessionById(id);
1113
+ const files = session?.metadata?.contextReview?.files;
1114
+ return files && typeof files === 'object' ? files : {};
1115
+ } catch (err) {
1116
+ console.error('Error getting session context review state:', err.message);
1117
+ return {};
1118
+ }
1119
+ },
1120
+
1121
+ updateSessionContextReview: (id, reviews = {}) => {
1122
+ try {
1123
+ const existingReviews = sessionDb.getSessionContextReview(id);
1124
+ const sanitizedReviews = Object.entries(reviews || {}).reduce((acc, [filePath, value]) => {
1125
+ if (!filePath || typeof filePath !== 'string') {
1126
+ return acc;
1127
+ }
1128
+
1129
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
1130
+ return acc;
1131
+ }
1132
+
1133
+ const reviewedAt = typeof value.reviewedAt === 'string' ? value.reviewedAt : null;
1134
+ const lastSeenAt = typeof value.lastSeenAt === 'string' ? value.lastSeenAt : null;
1135
+ const lastReviewedSeenAt = typeof value.lastReviewedSeenAt === 'string' ? value.lastReviewedSeenAt : null;
1136
+
1137
+ acc[filePath] = {
1138
+ reviewedAt,
1139
+ lastSeenAt,
1140
+ lastReviewedSeenAt,
1141
+ };
1142
+ return acc;
1143
+ }, {});
1144
+
1145
+ const nextFiles = {
1146
+ ...existingReviews,
1147
+ ...sanitizedReviews,
1148
+ };
1149
+
1150
+ sessionDb.updateSessionMetadata(id, {
1151
+ contextReview: {
1152
+ files: nextFiles,
1153
+ updatedAt: new Date().toISOString(),
1154
+ },
1155
+ });
1156
+
1157
+ return nextFiles;
1158
+ } catch (err) {
1159
+ console.error('Error updating session context review state:', err.message);
1160
+ return {};
1161
+ }
1162
+ },
1163
+
1164
+ deleteSession: (id) => {
1165
+ try {
1166
+ db.prepare('DELETE FROM session_metadata WHERE id = ?').run(id);
1167
+ } catch (err) {
1168
+ console.error('Error deleting session metadata:', err.message);
1169
+ }
1170
+ },
1171
+
1172
+ deleteSessionsByProject: (projectName) => {
1173
+ try {
1174
+ db.prepare('DELETE FROM session_metadata WHERE project_name = ?').run(projectName);
1175
+ } catch (err) {
1176
+ console.error('Error deleting project session metadata:', err.message);
1177
+ }
1178
+ }
1179
+ };
1180
+
1181
+ const tagDb = {
1182
+ ensureDefaultStageTags: (projectName) => {
1183
+ if (!projectName) {
1184
+ return [];
1185
+ }
1186
+
1187
+ const insert = db.prepare(`
1188
+ INSERT OR IGNORE INTO project_tags (
1189
+ project_name, tag_key, tag_type, label, color, sort_order, metadata
1190
+ ) VALUES (?, ?, 'stage', ?, ?, ?, ?)
1191
+ `);
1192
+
1193
+ const run = db.transaction(() => {
1194
+ DEFAULT_STAGE_TAGS.forEach((tag) => {
1195
+ insert.run(
1196
+ projectName,
1197
+ tag.tagKey,
1198
+ tag.label,
1199
+ tag.color,
1200
+ tag.sortOrder,
1201
+ null
1202
+ );
1203
+ });
1204
+ });
1205
+
1206
+ try {
1207
+ run();
1208
+ } catch (err) {
1209
+ console.error('Error ensuring default stage tags:', err.message);
1210
+ }
1211
+
1212
+ return tagDb.listProjectTags(projectName, 'stage');
1213
+ },
1214
+
1215
+ listProjectTags: (projectName, tagType = null) => {
1216
+ try {
1217
+ const rows = tagType
1218
+ ? db.prepare(`
1219
+ SELECT * FROM project_tags
1220
+ WHERE project_name = ? AND tag_type = ?
1221
+ ORDER BY sort_order ASC, label COLLATE NOCASE ASC, id ASC
1222
+ `).all(projectName, tagType)
1223
+ : db.prepare(`
1224
+ SELECT * FROM project_tags
1225
+ WHERE project_name = ?
1226
+ ORDER BY tag_type COLLATE NOCASE ASC, sort_order ASC, label COLLATE NOCASE ASC, id ASC
1227
+ `).all(projectName);
1228
+ return rows.map(parseTagRow).filter(Boolean);
1229
+ } catch (err) {
1230
+ console.error('Error listing project tags:', err.message);
1231
+ return [];
1232
+ }
1233
+ },
1234
+
1235
+ getTagByProjectAndKey: (projectName, tagType, tagKey) => {
1236
+ try {
1237
+ return parseTagRow(db.prepare(`
1238
+ SELECT * FROM project_tags
1239
+ WHERE project_name = ? AND tag_type = ? AND tag_key = ?
1240
+ `).get(projectName, tagType, tagKey));
1241
+ } catch (err) {
1242
+ console.error('Error getting project tag:', err.message);
1243
+ return null;
1244
+ }
1245
+ },
1246
+
1247
+ getTagsByIds: (projectName, tagIds = []) => {
1248
+ try {
1249
+ if (!Array.isArray(tagIds) || tagIds.length === 0) {
1250
+ return [];
1251
+ }
1252
+
1253
+ const normalizedIds = Array.from(new Set(
1254
+ tagIds
1255
+ .map((value) => Number(value))
1256
+ .filter((value) => Number.isInteger(value) && value > 0)
1257
+ ));
1258
+
1259
+ if (normalizedIds.length === 0) {
1260
+ return [];
1261
+ }
1262
+
1263
+ const placeholders = normalizedIds.map(() => '?').join(', ');
1264
+ const rows = db.prepare(`
1265
+ SELECT * FROM project_tags
1266
+ WHERE project_name = ? AND id IN (${placeholders})
1267
+ ORDER BY sort_order ASC, label COLLATE NOCASE ASC, id ASC
1268
+ `).all(projectName, ...normalizedIds);
1269
+ return rows.map(parseTagRow).filter(Boolean);
1270
+ } catch (err) {
1271
+ console.error('Error getting project tags by ids:', err.message);
1272
+ return [];
1273
+ }
1274
+ },
1275
+
1276
+ listTagsForSession: (sessionId) => {
1277
+ try {
1278
+ const rows = db.prepare(`
1279
+ SELECT
1280
+ pt.id,
1281
+ pt.project_name,
1282
+ pt.tag_key,
1283
+ pt.tag_type,
1284
+ pt.label,
1285
+ pt.color,
1286
+ pt.sort_order,
1287
+ pt.metadata,
1288
+ pt.created_at,
1289
+ stl.linked_by,
1290
+ stl.source,
1291
+ stl.metadata AS link_metadata,
1292
+ stl.created_at AS linked_at
1293
+ FROM session_tag_links stl
1294
+ JOIN project_tags pt ON pt.id = stl.tag_id
1295
+ WHERE stl.session_id = ?
1296
+ ORDER BY pt.sort_order ASC, pt.label COLLATE NOCASE ASC, pt.id ASC
1297
+ `).all(sessionId);
1298
+ return rows.map(parseTagRow).filter(Boolean);
1299
+ } catch (err) {
1300
+ console.error('Error listing session tags:', err.message);
1301
+ return [];
1302
+ }
1303
+ },
1304
+
1305
+ listSessionIdsForTag: (projectName, tagType, tagKey) => {
1306
+ try {
1307
+ const rows = db.prepare(`
1308
+ SELECT stl.session_id
1309
+ FROM session_tag_links stl
1310
+ JOIN project_tags pt ON pt.id = stl.tag_id
1311
+ WHERE pt.project_name = ? AND pt.tag_type = ? AND pt.tag_key = ?
1312
+ ORDER BY datetime(stl.created_at) DESC
1313
+ `).all(projectName, tagType, tagKey);
1314
+ return rows.map((row) => row.session_id).filter(Boolean);
1315
+ } catch (err) {
1316
+ console.error('Error listing session ids for tag:', err.message);
1317
+ return [];
1318
+ }
1319
+ },
1320
+
1321
+ replaceSessionTags: (sessionId, projectName, tagIds = [], options = {}) => {
1322
+ try {
1323
+ const selectedTags = tagDb.getTagsByIds(projectName, tagIds);
1324
+ const projectStageTags = tagDb.listProjectTags(projectName, 'stage');
1325
+ const normalizedTagIds = selectedTags.map((tag) => tag.id);
1326
+ const linkedBy = options.linkedBy || null;
1327
+ const source = options.source || null;
1328
+ const metadata = options.metadata && typeof options.metadata === 'object'
1329
+ ? JSON.stringify(options.metadata)
1330
+ : null;
1331
+
1332
+ const replace = db.transaction(() => {
1333
+ db.prepare(`
1334
+ DELETE FROM session_tag_links
1335
+ WHERE session_id = ?
1336
+ AND tag_id IN (SELECT id FROM project_tags WHERE project_name = ?)
1337
+ `).run(sessionId, projectName);
1338
+
1339
+ const insert = db.prepare(`
1340
+ INSERT OR IGNORE INTO session_tag_links (
1341
+ session_id, tag_id, linked_by, source, metadata
1342
+ ) VALUES (?, ?, ?, ?, ?)
1343
+ `);
1344
+
1345
+ normalizedTagIds.forEach((tagId) => {
1346
+ insert.run(sessionId, tagId, linkedBy, source, metadata);
1347
+ });
1348
+
1349
+ if (source === 'manual') {
1350
+ const session = parseSessionRow(db.prepare('SELECT * FROM session_metadata WHERE id = ?').get(sessionId));
1351
+ if (session) {
1352
+ const nextMetadata = applyManualStageTagDecisions(session.metadata, projectStageTags, selectedTags);
1353
+ db.prepare('UPDATE session_metadata SET metadata = ? WHERE id = ?').run(
1354
+ serializeMetadata(nextMetadata),
1355
+ sessionId
1356
+ );
1357
+ }
1358
+ }
1359
+ });
1360
+
1361
+ replace();
1362
+ return tagDb.listTagsForSession(sessionId);
1363
+ } catch (err) {
1364
+ console.error('Error replacing session tags:', err.message);
1365
+ return [];
1366
+ }
1367
+ },
1368
+
1369
+ appendSessionTagsByKeys: (sessionId, projectName, tagType, tagKeys = [], options = {}) => {
1370
+ try {
1371
+ const normalizedKeys = Array.from(new Set(
1372
+ (Array.isArray(tagKeys) ? tagKeys : [])
1373
+ .map((value) => String(value || '').trim())
1374
+ .filter(Boolean)
1375
+ ));
1376
+
1377
+ if (normalizedKeys.length === 0) {
1378
+ return tagDb.listTagsForSession(sessionId);
1379
+ }
1380
+
1381
+ const session = parseSessionRow(db.prepare('SELECT * FROM session_metadata WHERE id = ?').get(sessionId));
1382
+ const linkedBy = options.linkedBy || null;
1383
+ const source = options.source || null;
1384
+ const metadata = options.metadata && typeof options.metadata === 'object'
1385
+ ? JSON.stringify(options.metadata)
1386
+ : null;
1387
+ const insert = db.prepare(`
1388
+ INSERT OR IGNORE INTO session_tag_links (
1389
+ session_id, tag_id, linked_by, source, metadata
1390
+ ) VALUES (?, ?, ?, ?, ?)
1391
+ `);
1392
+
1393
+ const append = db.transaction(() => {
1394
+ normalizedKeys.forEach((tagKey) => {
1395
+ if (isAutomaticStageTagBlocked(session?.metadata, tagType, tagKey, source)) {
1396
+ return;
1397
+ }
1398
+
1399
+ const tag = tagDb.getTagByProjectAndKey(projectName, tagType, tagKey);
1400
+ if (tag) {
1401
+ insert.run(sessionId, tag.id, linkedBy, source, metadata);
1402
+ }
1403
+ });
1404
+ });
1405
+
1406
+ append();
1407
+ return tagDb.listTagsForSession(sessionId);
1408
+ } catch (err) {
1409
+ console.error('Error appending session tags:', err.message);
1410
+ return [];
1411
+ }
1412
+ },
1413
+ };
1414
+
1415
+ // Project index operations
1416
+ const projectDb = {
1417
+ // Upsert project (insert if not exists, update if exists)
1418
+ upsertProject: (id, userId, displayName, path, isStarred = 0, lastAccessed = null, metadata = null) => {
1419
+ try {
1420
+ const stmt = db.prepare(`
1421
+ INSERT INTO projects (id, user_id, display_name, path, is_starred, last_accessed, metadata)
1422
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1423
+ ON CONFLICT(id) DO UPDATE SET
1424
+ display_name = COALESCE(excluded.display_name, projects.display_name),
1425
+ path = COALESCE(excluded.path, projects.path),
1426
+ user_id = CASE WHEN projects.user_id IS NULL THEN excluded.user_id ELSE projects.user_id END,
1427
+ is_starred = COALESCE(excluded.is_starred, projects.is_starred),
1428
+ last_accessed = COALESCE(excluded.last_accessed, projects.last_accessed),
1429
+ metadata = COALESCE(excluded.metadata, projects.metadata)
1430
+ `);
1431
+ stmt.run(id, userId, displayName, path, isStarred, lastAccessed, metadata ? JSON.stringify(metadata) : null);
1432
+ } catch (err) {
1433
+ console.error('Error upserting project metadata:', err.message);
1434
+ }
1435
+ },
1436
+
1437
+ // Update project name ONLY
1438
+ updateProjectName: (id, displayName) => {
1439
+ try {
1440
+ db.prepare('UPDATE projects SET display_name = ? WHERE id = ?').run(displayName, id);
1441
+ } catch (err) {
1442
+ console.error('Error updating project name:', err.message);
1443
+ }
1444
+ },
1445
+
1446
+ // Get all projects (can filter by userId later)
1447
+ getAllProjects: (userId = null) => {
1448
+ try {
1449
+ const query = userId ? 'SELECT * FROM projects WHERE user_id = ?' : 'SELECT * FROM projects';
1450
+ const rows = userId ? db.prepare(query).all(userId) : db.prepare(query).all();
1451
+ return rows.map(row => ({
1452
+ ...row,
1453
+ metadata: row.metadata ? JSON.parse(row.metadata) : null
1454
+ }));
1455
+ } catch (err) {
1456
+ console.error('Error getting projects:', err.message);
1457
+ return [];
1458
+ }
1459
+ },
1460
+
1461
+ // Get project by its encoded ID
1462
+ getProjectById: (id) => {
1463
+ try {
1464
+ const row = db.prepare('SELECT * FROM projects WHERE id = ?').get(id);
1465
+ if (row && row.metadata) {
1466
+ row.metadata = JSON.parse(row.metadata);
1467
+ }
1468
+ return row;
1469
+ } catch (err) {
1470
+ console.error('Error getting project metadata:', err.message);
1471
+ return null;
1472
+ }
1473
+ },
1474
+
1475
+ // Get project by its file-system path (uses idx_projects_path index)
1476
+ getProjectByPath: (projectPath, userId = null) => {
1477
+ try {
1478
+ const query = userId
1479
+ ? 'SELECT * FROM projects WHERE path = ? AND user_id = ?'
1480
+ : 'SELECT * FROM projects WHERE path = ?';
1481
+ const row = userId
1482
+ ? db.prepare(query).get(projectPath, userId)
1483
+ : db.prepare(query).get(projectPath);
1484
+ if (row && row.metadata) {
1485
+ row.metadata = JSON.parse(row.metadata);
1486
+ }
1487
+ return row || null;
1488
+ } catch (err) {
1489
+ console.error('Error getting project by path:', err.message);
1490
+ return null;
1491
+ }
1492
+ },
1493
+
1494
+ toggleStar: (id, isStarred) => {
1495
+ try {
1496
+ db.prepare('UPDATE projects SET is_starred = ? WHERE id = ?').run(isStarred ? 1 : 0, id);
1497
+ } catch (err) {
1498
+ console.error('Error toggling project star:', err.message);
1499
+ }
1500
+ },
1501
+
1502
+ deleteProject: (id) => {
1503
+ try {
1504
+ db.prepare('DELETE FROM projects WHERE id = ?').run(id);
1505
+ } catch (err) {
1506
+ console.error('Error deleting project metadata:', err.message);
1507
+ }
1508
+ },
1509
+
1510
+ updateProjectPath: (id, projectPath) => {
1511
+ try {
1512
+ db.prepare('UPDATE projects SET path = ? WHERE id = ?').run(projectPath, id);
1513
+ } catch (err) {
1514
+ console.error('Error updating project path:', err.message);
1515
+ }
1516
+ },
1517
+
1518
+ migrateProjectIdentity: (oldId, newId, projectPath) => {
1519
+ const migrate = db.transaction(() => {
1520
+ db.prepare('UPDATE projects SET id = ?, path = ? WHERE id = ?').run(newId, projectPath, oldId);
1521
+ db.prepare('UPDATE session_metadata SET project_name = ? WHERE project_name = ?').run(newId, oldId);
1522
+ });
1523
+
1524
+ try {
1525
+ migrate();
1526
+ } catch (err) {
1527
+ console.error('Error migrating project identity:', err.message);
1528
+ throw err;
1529
+ }
1530
+ }
1531
+ };
1532
+
1533
+ // References (literature library) database operations
1534
+ const referencesDb = {
1535
+ /**
1536
+ * Batch upsert references from Zotero or other sources.
1537
+ * Deduplicates by source_id for the given user.
1538
+ */
1539
+ syncFromZotero: (userId, items) => {
1540
+ const upsert = db.prepare(`
1541
+ INSERT INTO references_library (id, user_id, title, authors, year, abstract, doi, url, journal, item_type, source, source_id, keywords, citation_key, raw_data, updated_at)
1542
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'zotero', ?, ?, ?, ?, CURRENT_TIMESTAMP)
1543
+ ON CONFLICT(id) DO UPDATE SET
1544
+ title = excluded.title,
1545
+ authors = excluded.authors,
1546
+ year = excluded.year,
1547
+ abstract = excluded.abstract,
1548
+ doi = excluded.doi,
1549
+ url = excluded.url,
1550
+ journal = excluded.journal,
1551
+ item_type = excluded.item_type,
1552
+ keywords = excluded.keywords,
1553
+ citation_key = excluded.citation_key,
1554
+ raw_data = excluded.raw_data,
1555
+ updated_at = CURRENT_TIMESTAMP
1556
+ `);
1557
+
1558
+ const insertTag = db.prepare(`
1559
+ INSERT OR IGNORE INTO reference_tags (reference_id, tag) VALUES (?, ?)
1560
+ `);
1561
+
1562
+ const deleteTags = db.prepare(`DELETE FROM reference_tags WHERE reference_id = ?`);
1563
+
1564
+ const tx = db.transaction((rows) => {
1565
+ const ids = [];
1566
+ for (const item of rows) {
1567
+ // Deterministic id: user + source_id
1568
+ const id = `zotero_${userId}_${item.sourceId}`;
1569
+ upsert.run(
1570
+ id,
1571
+ userId,
1572
+ item.title,
1573
+ JSON.stringify(item.authors || []),
1574
+ item.year,
1575
+ item.abstract,
1576
+ item.doi,
1577
+ item.url,
1578
+ item.journal,
1579
+ item.itemType || 'article',
1580
+ item.sourceId,
1581
+ JSON.stringify(item.keywords || []),
1582
+ item.citationKey,
1583
+ item.rawData ? JSON.stringify(item.rawData) : null,
1584
+ );
1585
+ // Clean stale tags, then re-insert
1586
+ deleteTags.run(id);
1587
+ for (const tag of item.keywords || []) {
1588
+ insertTag.run(id, tag);
1589
+ }
1590
+ ids.push(id);
1591
+ }
1592
+ return ids;
1593
+ });
1594
+
1595
+ try {
1596
+ return tx(items);
1597
+ } catch (err) {
1598
+ throw err;
1599
+ }
1600
+ },
1601
+
1602
+ /**
1603
+ * Import references from BibTeX (or other non-Zotero sources).
1604
+ */
1605
+ importReferences: (userId, items, source = 'bibtex') => {
1606
+ const upsert = db.prepare(`
1607
+ INSERT INTO references_library (id, user_id, title, authors, year, abstract, doi, url, journal, item_type, source, source_id, keywords, citation_key, updated_at)
1608
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
1609
+ ON CONFLICT(id) DO UPDATE SET
1610
+ title = excluded.title,
1611
+ authors = excluded.authors,
1612
+ year = excluded.year,
1613
+ abstract = excluded.abstract,
1614
+ doi = excluded.doi,
1615
+ url = excluded.url,
1616
+ journal = excluded.journal,
1617
+ item_type = excluded.item_type,
1618
+ keywords = excluded.keywords,
1619
+ citation_key = excluded.citation_key,
1620
+ updated_at = CURRENT_TIMESTAMP
1621
+ `);
1622
+
1623
+ const insertTag = db.prepare(`
1624
+ INSERT OR IGNORE INTO reference_tags (reference_id, tag) VALUES (?, ?)
1625
+ `);
1626
+
1627
+ const deleteTags = db.prepare(`DELETE FROM reference_tags WHERE reference_id = ?`);
1628
+
1629
+ const tx = db.transaction((rows) => {
1630
+ const ids = [];
1631
+ for (const item of rows) {
1632
+ // When no citationKey, generate deterministic ID from content
1633
+ let key = item.citationKey;
1634
+ if (!key) {
1635
+ const hash = crypto.createHash('sha256')
1636
+ .update(`${item.title || ''}|${JSON.stringify(item.authors || [])}|${item.year || ''}`)
1637
+ .digest('hex')
1638
+ .slice(0, 16);
1639
+ key = hash;
1640
+ }
1641
+ const id = `${source}_${userId}_${key}`;
1642
+ upsert.run(
1643
+ id,
1644
+ userId,
1645
+ item.title,
1646
+ JSON.stringify(item.authors || []),
1647
+ item.year,
1648
+ item.abstract,
1649
+ item.doi,
1650
+ item.url,
1651
+ item.journal,
1652
+ item.itemType || 'article',
1653
+ source,
1654
+ item.citationKey || null,
1655
+ JSON.stringify(item.keywords || []),
1656
+ item.citationKey || null,
1657
+ );
1658
+ // Clean stale tags, then re-insert
1659
+ deleteTags.run(id);
1660
+ for (const tag of item.keywords || []) {
1661
+ insertTag.run(id, tag);
1662
+ }
1663
+ ids.push(id);
1664
+ }
1665
+ return ids;
1666
+ });
1667
+
1668
+ try {
1669
+ return tx(items);
1670
+ } catch (err) {
1671
+ throw err;
1672
+ }
1673
+ },
1674
+
1675
+ /** List user references with optional search and pagination. */
1676
+ getUserReferences: (userId, { search, tags, limit = 50, offset = 0 } = {}) => {
1677
+ try {
1678
+ let query = 'SELECT * FROM references_library WHERE user_id = ?';
1679
+ const params = [userId];
1680
+
1681
+ if (search) {
1682
+ query += ' AND (title LIKE ? OR authors LIKE ? OR journal LIKE ? OR abstract LIKE ?)';
1683
+ const term = `%${search}%`;
1684
+ params.push(term, term, term, term);
1685
+ }
1686
+
1687
+ if (tags && tags.length > 0) {
1688
+ query += ` AND id IN (SELECT reference_id FROM reference_tags WHERE tag IN (${tags.map(() => '?').join(',')}))`;
1689
+ params.push(...tags);
1690
+ }
1691
+
1692
+ query += ' ORDER BY updated_at DESC LIMIT ? OFFSET ?';
1693
+ params.push(limit, offset);
1694
+
1695
+ const rows = db.prepare(query).all(...params);
1696
+ return rows.map((r) => ({
1697
+ ...r,
1698
+ authors: r.authors ? JSON.parse(r.authors) : [],
1699
+ keywords: r.keywords ? JSON.parse(r.keywords) : [],
1700
+ raw_data: undefined, // Don't send raw_data in list
1701
+ }));
1702
+ } catch (err) {
1703
+ throw err;
1704
+ }
1705
+ },
1706
+
1707
+ /** Single reference detail. */
1708
+ getReference: (id, userId) => {
1709
+ try {
1710
+ const row = db.prepare('SELECT * FROM references_library WHERE id = ? AND user_id = ?').get(id, userId);
1711
+ if (!row) return null;
1712
+ return {
1713
+ ...row,
1714
+ authors: row.authors ? JSON.parse(row.authors) : [],
1715
+ keywords: row.keywords ? JSON.parse(row.keywords) : [],
1716
+ raw_data: row.raw_data ? JSON.parse(row.raw_data) : null,
1717
+ };
1718
+ } catch (err) {
1719
+ throw err;
1720
+ }
1721
+ },
1722
+
1723
+ /** Get references linked to a project. */
1724
+ getProjectReferences: (projectId, userId) => {
1725
+ try {
1726
+ const rows = db.prepare(`
1727
+ SELECT r.*, pr.added_at AS linked_at
1728
+ FROM references_library r
1729
+ JOIN project_references pr ON pr.reference_id = r.id
1730
+ WHERE pr.project_id = ? AND r.user_id = ?
1731
+ ORDER BY pr.added_at DESC
1732
+ `).all(projectId, userId);
1733
+ return rows.map((r) => ({
1734
+ ...r,
1735
+ authors: r.authors ? JSON.parse(r.authors) : [],
1736
+ keywords: r.keywords ? JSON.parse(r.keywords) : [],
1737
+ raw_data: undefined,
1738
+ }));
1739
+ } catch (err) {
1740
+ throw err;
1741
+ }
1742
+ },
1743
+
1744
+ /** Link a reference to a project (verifies ownership). */
1745
+ linkToProject: (projectId, referenceId, userId) => {
1746
+ try {
1747
+ const ref = db.prepare('SELECT id FROM references_library WHERE id = ? AND user_id = ?').get(referenceId, userId);
1748
+ if (!ref) return false;
1749
+ db.prepare('INSERT OR IGNORE INTO project_references (project_id, reference_id) VALUES (?, ?)').run(projectId, referenceId);
1750
+ return true;
1751
+ } catch (err) {
1752
+ throw err;
1753
+ }
1754
+ },
1755
+
1756
+ /** Unlink a reference from a project (verifies ownership). */
1757
+ unlinkFromProject: (projectId, referenceId, userId) => {
1758
+ try {
1759
+ const ref = db.prepare('SELECT id FROM references_library WHERE id = ? AND user_id = ?').get(referenceId, userId);
1760
+ if (!ref) return false;
1761
+ const result = db.prepare('DELETE FROM project_references WHERE project_id = ? AND reference_id = ?').run(projectId, referenceId);
1762
+ return result.changes > 0;
1763
+ } catch (err) {
1764
+ throw err;
1765
+ }
1766
+ },
1767
+
1768
+ /** Bulk-link an array of reference IDs to a project. */
1769
+ bulkLinkIds: (projectId, referenceIds) => {
1770
+ const insert = db.prepare('INSERT OR IGNORE INTO project_references (project_id, reference_id) VALUES (?, ?)');
1771
+ const tx = db.transaction((ids) => {
1772
+ let count = 0;
1773
+ for (const id of ids) {
1774
+ count += insert.run(projectId, id).changes;
1775
+ }
1776
+ return count;
1777
+ });
1778
+ return tx(referenceIds);
1779
+ },
1780
+
1781
+ /** Get all unique tags for a user. */
1782
+ getTags: (userId) => {
1783
+ try {
1784
+ const rows = db.prepare(`
1785
+ SELECT DISTINCT rt.tag, COUNT(*) as count
1786
+ FROM reference_tags rt
1787
+ JOIN references_library r ON r.id = rt.reference_id
1788
+ WHERE r.user_id = ?
1789
+ GROUP BY rt.tag
1790
+ ORDER BY count DESC
1791
+ `).all(userId);
1792
+ return rows;
1793
+ } catch (err) {
1794
+ throw err;
1795
+ }
1796
+ },
1797
+
1798
+ /** Mark a reference as having its PDF cached. */
1799
+ setPdfCached: (id, cached = true) => {
1800
+ try {
1801
+ db.prepare('UPDATE references_library SET pdf_cached = ? WHERE id = ?').run(cached ? 1 : 0, id);
1802
+ } catch (err) {
1803
+ throw err;
1804
+ }
1805
+ },
1806
+
1807
+ /** Delete a reference. */
1808
+ deleteReference: (userId, referenceId) => {
1809
+ try {
1810
+ const result = db.prepare('DELETE FROM references_library WHERE id = ? AND user_id = ?').run(referenceId, userId);
1811
+ return result.changes > 0;
1812
+ } catch (err) {
1813
+ throw err;
1814
+ }
1815
+ },
1816
+
1817
+ /** Bulk-delete references by id list. Returns number of deleted rows. */
1818
+ bulkDeleteReferences: (userId, referenceIds) => {
1819
+ if (!referenceIds || referenceIds.length === 0) return 0;
1820
+ // Chunk to avoid SQLite parameter limit
1821
+ const CHUNK_SIZE = 500;
1822
+ let total = 0;
1823
+ const tx = db.transaction(() => {
1824
+ for (let i = 0; i < referenceIds.length; i += CHUNK_SIZE) {
1825
+ const chunk = referenceIds.slice(i, i + CHUNK_SIZE);
1826
+ const placeholders = chunk.map(() => '?').join(',');
1827
+ const result = db.prepare(
1828
+ `DELETE FROM references_library WHERE user_id = ? AND id IN (${placeholders})`
1829
+ ).run(userId, ...chunk);
1830
+ total += result.changes;
1831
+ }
1832
+ });
1833
+ tx();
1834
+ return total;
1835
+ },
1836
+ };
1837
+
1838
+ export {
1839
+ db,
1840
+ initializeDatabase,
1841
+ userDb,
1842
+ autoResearchDb,
1843
+ appSettingsDb,
1844
+ apiKeysDb,
1845
+ credentialsDb,
1846
+ githubTokensDb, // Backward compatibility
1847
+ sessionDb,
1848
+ tagDb,
1849
+ projectDb,
1850
+ referencesDb,
1851
+ normalizeSessionTimestamp
1852
+ };