@shaykec/bridge 0.4.19 → 0.4.21

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 (319) hide show
  1. package/README.md +2 -2
  2. package/canvas-dist/assets/{_basePickBy-BOTBlJNd.js → _basePickBy-BovdgFIW.js} +1 -1
  3. package/canvas-dist/assets/_basePickBy-BtkHe2u_.js +1 -0
  4. package/canvas-dist/assets/_basePickBy-C0936578.js +1 -0
  5. package/canvas-dist/assets/_basePickBy-CE2Qvuh7.js +1 -0
  6. package/canvas-dist/assets/_basePickBy-DV6sX4CG.js +1 -0
  7. package/canvas-dist/assets/_basePickBy-DZX6ZNMT.js +1 -0
  8. package/canvas-dist/assets/{_baseUniq-EF6Y2_Wm.js → _baseUniq-B7dN28TM.js} +1 -1
  9. package/canvas-dist/assets/_baseUniq-Cl23fCdR.js +1 -0
  10. package/canvas-dist/assets/_baseUniq-CojWFw7B.js +1 -0
  11. package/canvas-dist/assets/_baseUniq-DA640BJl.js +1 -0
  12. package/canvas-dist/assets/_baseUniq-Ds-62CCj.js +1 -0
  13. package/canvas-dist/assets/_baseUniq-KG7SRw9H.js +1 -0
  14. package/canvas-dist/assets/{arc-C_vIirh2.js → arc-7E9FFKlC.js} +1 -1
  15. package/canvas-dist/assets/arc-BSMfRZtt.js +1 -0
  16. package/canvas-dist/assets/arc-C6nT-koR.js +1 -0
  17. package/canvas-dist/assets/arc-D_fOnjmo.js +1 -0
  18. package/canvas-dist/assets/arc-Khfvgkr3.js +1 -0
  19. package/canvas-dist/assets/arc-ieS-i42x.js +1 -0
  20. package/canvas-dist/assets/{architectureDiagram-VXUJARFQ-EvM6tQ7I.js → architectureDiagram-VXUJARFQ-DF4t6GQD.js} +1 -1
  21. package/canvas-dist/assets/architectureDiagram-VXUJARFQ-DXgSlsio.js +36 -0
  22. package/canvas-dist/assets/architectureDiagram-VXUJARFQ-DiomxPB4.js +36 -0
  23. package/canvas-dist/assets/architectureDiagram-VXUJARFQ-DnFaxvXD.js +36 -0
  24. package/canvas-dist/assets/architectureDiagram-VXUJARFQ-Dt38C0LJ.js +36 -0
  25. package/canvas-dist/assets/architectureDiagram-VXUJARFQ-egbtMwua.js +36 -0
  26. package/canvas-dist/assets/{blockDiagram-VD42YOAC-B_rbZyqc.js → blockDiagram-VD42YOAC-CUNKQd-b.js} +1 -1
  27. package/canvas-dist/assets/blockDiagram-VD42YOAC-D-NiLXxd.js +122 -0
  28. package/canvas-dist/assets/blockDiagram-VD42YOAC-Dx6Dh9gg.js +122 -0
  29. package/canvas-dist/assets/blockDiagram-VD42YOAC-_r-PmlQy.js +122 -0
  30. package/canvas-dist/assets/blockDiagram-VD42YOAC-bvYKZLMc.js +122 -0
  31. package/canvas-dist/assets/blockDiagram-VD42YOAC-l85QT9Ig.js +122 -0
  32. package/canvas-dist/assets/{c4Diagram-YG6GDRKO-J9PHecY3.js → c4Diagram-YG6GDRKO-BWKCTyQi.js} +1 -1
  33. package/canvas-dist/assets/c4Diagram-YG6GDRKO-CbXs2xzC.js +10 -0
  34. package/canvas-dist/assets/c4Diagram-YG6GDRKO-CjiS-GNK.js +10 -0
  35. package/canvas-dist/assets/c4Diagram-YG6GDRKO-D7SnLlHp.js +10 -0
  36. package/canvas-dist/assets/c4Diagram-YG6GDRKO-RTTCSVf2.js +10 -0
  37. package/canvas-dist/assets/c4Diagram-YG6GDRKO-yvqJ_AqX.js +10 -0
  38. package/canvas-dist/assets/channel-CSXq7GP6.js +1 -0
  39. package/canvas-dist/assets/channel-CvujjGiJ.js +1 -0
  40. package/canvas-dist/assets/channel-D959Iony.js +1 -0
  41. package/canvas-dist/assets/channel-DOSwCnrB.js +1 -0
  42. package/canvas-dist/assets/channel-sw61LzxF.js +1 -0
  43. package/canvas-dist/assets/channel-vZVnNhOK.js +1 -0
  44. package/canvas-dist/assets/{chunk-4BX2VUAB-DjcN96Mk.js → chunk-4BX2VUAB-BBjuAwXr.js} +1 -1
  45. package/canvas-dist/assets/chunk-4BX2VUAB-BXRNyucU.js +1 -0
  46. package/canvas-dist/assets/chunk-4BX2VUAB-Bgq5Z77T.js +1 -0
  47. package/canvas-dist/assets/chunk-4BX2VUAB-BuoMCMCr.js +1 -0
  48. package/canvas-dist/assets/chunk-4BX2VUAB-COD5n7vg.js +1 -0
  49. package/canvas-dist/assets/chunk-4BX2VUAB-K8DepKJO.js +1 -0
  50. package/canvas-dist/assets/{chunk-55IACEB6-CTdcUQSV.js → chunk-55IACEB6-Bic_bMrQ.js} +1 -1
  51. package/canvas-dist/assets/chunk-55IACEB6-DEy2QUDq.js +1 -0
  52. package/canvas-dist/assets/chunk-55IACEB6-Dcgbmfzg.js +1 -0
  53. package/canvas-dist/assets/chunk-55IACEB6-DfmuNm_E.js +1 -0
  54. package/canvas-dist/assets/chunk-55IACEB6-DlQRcczm.js +1 -0
  55. package/canvas-dist/assets/chunk-55IACEB6-p2qMY-fm.js +1 -0
  56. package/canvas-dist/assets/{chunk-B4BG7PRW-Dcov7eRi.js → chunk-B4BG7PRW-BpbyxBP2.js} +1 -1
  57. package/canvas-dist/assets/chunk-B4BG7PRW-CCPqvPrP.js +165 -0
  58. package/canvas-dist/assets/chunk-B4BG7PRW-CEeDPAki.js +165 -0
  59. package/canvas-dist/assets/chunk-B4BG7PRW-D2UFN_2M.js +165 -0
  60. package/canvas-dist/assets/chunk-B4BG7PRW-DFI5h6HC.js +165 -0
  61. package/canvas-dist/assets/chunk-B4BG7PRW-DKOiFGMU.js +165 -0
  62. package/canvas-dist/assets/{chunk-DI55MBZ5-DUJCBZzM.js → chunk-DI55MBZ5-BV6nHjNQ.js} +1 -1
  63. package/canvas-dist/assets/chunk-DI55MBZ5-CEZJmC0E.js +220 -0
  64. package/canvas-dist/assets/chunk-DI55MBZ5-DOZT99Ek.js +220 -0
  65. package/canvas-dist/assets/chunk-DI55MBZ5-DmC2LoG2.js +220 -0
  66. package/canvas-dist/assets/chunk-DI55MBZ5-DpkcJdZP.js +220 -0
  67. package/canvas-dist/assets/chunk-DI55MBZ5-fVTGx0zh.js +220 -0
  68. package/canvas-dist/assets/{chunk-FMBD7UC4-EfGA9ufe.js → chunk-FMBD7UC4-BOCyQpI7.js} +1 -1
  69. package/canvas-dist/assets/chunk-FMBD7UC4-C76FrRL8.js +15 -0
  70. package/canvas-dist/assets/chunk-FMBD7UC4-CAq-btWc.js +15 -0
  71. package/canvas-dist/assets/chunk-FMBD7UC4-CidVsej6.js +15 -0
  72. package/canvas-dist/assets/chunk-FMBD7UC4-DPpfskdX.js +15 -0
  73. package/canvas-dist/assets/chunk-FMBD7UC4-DnLtclge.js +15 -0
  74. package/canvas-dist/assets/{chunk-QN33PNHL-Cu6V1xBU.js → chunk-QN33PNHL-BclpCUi8.js} +1 -1
  75. package/canvas-dist/assets/chunk-QN33PNHL-DDUw8IU1.js +1 -0
  76. package/canvas-dist/assets/chunk-QN33PNHL-DdJFAUXw.js +1 -0
  77. package/canvas-dist/assets/chunk-QN33PNHL-DjV4jUn9.js +1 -0
  78. package/canvas-dist/assets/chunk-QN33PNHL-N-HTycqU.js +1 -0
  79. package/canvas-dist/assets/chunk-QN33PNHL-sd8p21DW.js +1 -0
  80. package/canvas-dist/assets/{chunk-QZHKN3VN-avF3sH_r.js → chunk-QZHKN3VN-B6mT-JkP.js} +1 -1
  81. package/canvas-dist/assets/chunk-QZHKN3VN-BCo8pc7x.js +1 -0
  82. package/canvas-dist/assets/chunk-QZHKN3VN-C8IIu6es.js +1 -0
  83. package/canvas-dist/assets/chunk-QZHKN3VN-D9FF492U.js +1 -0
  84. package/canvas-dist/assets/chunk-QZHKN3VN-DWMbUjXT.js +1 -0
  85. package/canvas-dist/assets/chunk-QZHKN3VN-l5FBJ77g.js +1 -0
  86. package/canvas-dist/assets/{chunk-TZMSLE5B-CkWW-qpk.js → chunk-TZMSLE5B-BASt-UWt.js} +1 -1
  87. package/canvas-dist/assets/chunk-TZMSLE5B-BCfaZWLT.js +1 -0
  88. package/canvas-dist/assets/chunk-TZMSLE5B-BKIk_hBR.js +1 -0
  89. package/canvas-dist/assets/chunk-TZMSLE5B-C4pt-Ir8.js +1 -0
  90. package/canvas-dist/assets/chunk-TZMSLE5B-DwGlELvo.js +1 -0
  91. package/canvas-dist/assets/chunk-TZMSLE5B-jJKG-WvJ.js +1 -0
  92. package/canvas-dist/assets/classDiagram-2ON5EDUG-B7YQfPU4.js +1 -0
  93. package/canvas-dist/assets/classDiagram-2ON5EDUG-BZ61MaHY.js +1 -0
  94. package/canvas-dist/assets/classDiagram-2ON5EDUG-CGseYor2.js +1 -0
  95. package/canvas-dist/assets/classDiagram-2ON5EDUG-CKzOc99J.js +1 -0
  96. package/canvas-dist/assets/classDiagram-2ON5EDUG-Ce_LPjwW.js +1 -0
  97. package/canvas-dist/assets/classDiagram-2ON5EDUG-DorPdibv.js +1 -0
  98. package/canvas-dist/assets/classDiagram-v2-WZHVMYZB-B7YQfPU4.js +1 -0
  99. package/canvas-dist/assets/classDiagram-v2-WZHVMYZB-BZ61MaHY.js +1 -0
  100. package/canvas-dist/assets/classDiagram-v2-WZHVMYZB-CGseYor2.js +1 -0
  101. package/canvas-dist/assets/classDiagram-v2-WZHVMYZB-CKzOc99J.js +1 -0
  102. package/canvas-dist/assets/classDiagram-v2-WZHVMYZB-Ce_LPjwW.js +1 -0
  103. package/canvas-dist/assets/classDiagram-v2-WZHVMYZB-DorPdibv.js +1 -0
  104. package/canvas-dist/assets/clone-74KSto7H.js +1 -0
  105. package/canvas-dist/assets/clone-CJQgAYVe.js +1 -0
  106. package/canvas-dist/assets/clone-DLeTuhHE.js +1 -0
  107. package/canvas-dist/assets/clone-D_IHK_lQ.js +1 -0
  108. package/canvas-dist/assets/clone-DxMUv1L9.js +1 -0
  109. package/canvas-dist/assets/clone-UNKf_nED.js +1 -0
  110. package/canvas-dist/assets/{cose-bilkent-S5V4N54A-DDE4zf7X.js → cose-bilkent-S5V4N54A-BTyQiCkr.js} +1 -1
  111. package/canvas-dist/assets/cose-bilkent-S5V4N54A-BtPAe24N.js +1 -0
  112. package/canvas-dist/assets/cose-bilkent-S5V4N54A-DIjE7V3m.js +1 -0
  113. package/canvas-dist/assets/cose-bilkent-S5V4N54A-DKL_BGvE.js +1 -0
  114. package/canvas-dist/assets/cose-bilkent-S5V4N54A-LZ4OsCLU.js +1 -0
  115. package/canvas-dist/assets/cose-bilkent-S5V4N54A-XWeJtgga.js +1 -0
  116. package/canvas-dist/assets/{dagre-6UL2VRFP-BD6MGb7B.js → dagre-6UL2VRFP-BJ2vcFwR.js} +1 -1
  117. package/canvas-dist/assets/dagre-6UL2VRFP-C1FlE5s8.js +4 -0
  118. package/canvas-dist/assets/dagre-6UL2VRFP-C3BWFgl6.js +4 -0
  119. package/canvas-dist/assets/dagre-6UL2VRFP-CUnx73Rf.js +4 -0
  120. package/canvas-dist/assets/dagre-6UL2VRFP-Do10BY1y.js +4 -0
  121. package/canvas-dist/assets/dagre-6UL2VRFP-rOZEkrsg.js +4 -0
  122. package/canvas-dist/assets/diagram-PSM6KHXK-BGi_qzbq.js +24 -0
  123. package/canvas-dist/assets/diagram-PSM6KHXK-C3Nv7h_j.js +24 -0
  124. package/canvas-dist/assets/diagram-PSM6KHXK-CsMy-r0n.js +24 -0
  125. package/canvas-dist/assets/diagram-PSM6KHXK-Dj8g7kGt.js +24 -0
  126. package/canvas-dist/assets/diagram-PSM6KHXK-Dxb1w_7r.js +24 -0
  127. package/canvas-dist/assets/{diagram-PSM6KHXK-yyu-ytzf.js → diagram-PSM6KHXK-kVMBkEyV.js} +1 -1
  128. package/canvas-dist/assets/{diagram-QEK2KX5R-B_H957Uf.js → diagram-QEK2KX5R-4bsrr1WZ.js} +1 -1
  129. package/canvas-dist/assets/diagram-QEK2KX5R-Bv7BmKfI.js +43 -0
  130. package/canvas-dist/assets/diagram-QEK2KX5R-C_FLN6hv.js +43 -0
  131. package/canvas-dist/assets/diagram-QEK2KX5R-Csuk5L3z.js +43 -0
  132. package/canvas-dist/assets/diagram-QEK2KX5R-D5Aszgz4.js +43 -0
  133. package/canvas-dist/assets/diagram-QEK2KX5R-DX58f87l.js +43 -0
  134. package/canvas-dist/assets/{diagram-S2PKOQOG-DuebuBVv.js → diagram-S2PKOQOG-1Q7hwiSd.js} +1 -1
  135. package/canvas-dist/assets/diagram-S2PKOQOG-Bz9Vxi5V.js +24 -0
  136. package/canvas-dist/assets/diagram-S2PKOQOG-CdWgZIIc.js +24 -0
  137. package/canvas-dist/assets/diagram-S2PKOQOG-DBicbKFU.js +24 -0
  138. package/canvas-dist/assets/diagram-S2PKOQOG-DsXKwPtU.js +24 -0
  139. package/canvas-dist/assets/diagram-S2PKOQOG-L_SMHLXs.js +24 -0
  140. package/canvas-dist/assets/{erDiagram-Q2GNP2WA-AxqPt6IZ.js → erDiagram-Q2GNP2WA-BYu7fh6H.js} +1 -1
  141. package/canvas-dist/assets/erDiagram-Q2GNP2WA-CvnQ69BF.js +60 -0
  142. package/canvas-dist/assets/erDiagram-Q2GNP2WA-D3xm-Tdm.js +60 -0
  143. package/canvas-dist/assets/erDiagram-Q2GNP2WA-DIPpD8sj.js +60 -0
  144. package/canvas-dist/assets/erDiagram-Q2GNP2WA-DNgu6dMd.js +60 -0
  145. package/canvas-dist/assets/erDiagram-Q2GNP2WA-Decm8aB4.js +60 -0
  146. package/canvas-dist/assets/{flowDiagram-NV44I4VS-mDhW3D3Q.js → flowDiagram-NV44I4VS-2ymk2kw2.js} +1 -1
  147. package/canvas-dist/assets/flowDiagram-NV44I4VS-BEPFOt6U.js +162 -0
  148. package/canvas-dist/assets/flowDiagram-NV44I4VS-BwqXYGfK.js +162 -0
  149. package/canvas-dist/assets/flowDiagram-NV44I4VS-CS1jax_z.js +162 -0
  150. package/canvas-dist/assets/flowDiagram-NV44I4VS-DQz5bf7r.js +162 -0
  151. package/canvas-dist/assets/flowDiagram-NV44I4VS-KW4T1sqF.js +162 -0
  152. package/canvas-dist/assets/{ganttDiagram-JELNMOA3-sA8pHJPp.js → ganttDiagram-JELNMOA3-B811prZt.js} +1 -1
  153. package/canvas-dist/assets/ganttDiagram-JELNMOA3-C75pWm7X.js +267 -0
  154. package/canvas-dist/assets/ganttDiagram-JELNMOA3-CWsbo0fn.js +267 -0
  155. package/canvas-dist/assets/ganttDiagram-JELNMOA3-CbJozPBN.js +267 -0
  156. package/canvas-dist/assets/ganttDiagram-JELNMOA3-Co0cFt4c.js +267 -0
  157. package/canvas-dist/assets/ganttDiagram-JELNMOA3-I4PDqrRh.js +267 -0
  158. package/canvas-dist/assets/{gitGraphDiagram-V2S2FVAM-CvLzvhKr.js → gitGraphDiagram-V2S2FVAM-B-z0cLPt.js} +1 -1
  159. package/canvas-dist/assets/gitGraphDiagram-V2S2FVAM-Be40z-LF.js +65 -0
  160. package/canvas-dist/assets/gitGraphDiagram-V2S2FVAM-BejNaAVm.js +65 -0
  161. package/canvas-dist/assets/gitGraphDiagram-V2S2FVAM-BqWDYr0X.js +65 -0
  162. package/canvas-dist/assets/gitGraphDiagram-V2S2FVAM-DSvWGY-e.js +65 -0
  163. package/canvas-dist/assets/gitGraphDiagram-V2S2FVAM-HLYbyNJ5.js +65 -0
  164. package/canvas-dist/assets/{graph-BVZqMrwW.js → graph-BE8KKsdf.js} +1 -1
  165. package/canvas-dist/assets/graph-D6DzzszU.js +1 -0
  166. package/canvas-dist/assets/graph-DFR8Y_8s.js +1 -0
  167. package/canvas-dist/assets/graph-Da385cDY.js +1 -0
  168. package/canvas-dist/assets/graph-MU7gZz2B.js +1 -0
  169. package/canvas-dist/assets/graph-wjSBJwnf.js +1 -0
  170. package/canvas-dist/assets/index--ztw-8Rw.js +647 -0
  171. package/canvas-dist/assets/{index-CF3qc2Xb.js → index-5TpIM6B1.js} +1 -1
  172. package/canvas-dist/assets/index-6GBZ9nXN.css +32 -0
  173. package/canvas-dist/assets/index-BSswTuBk.js +11 -0
  174. package/canvas-dist/assets/index-BVvhMmjs.js +11 -0
  175. package/canvas-dist/assets/index-BY92Mj5g.js +572 -0
  176. package/canvas-dist/assets/index-CV7palC3.js +572 -0
  177. package/canvas-dist/assets/index-D9bmQGsB.js +11 -0
  178. package/canvas-dist/assets/index-DDIKkGv8.js +592 -0
  179. package/canvas-dist/assets/index-Dyo0NkPb.js +574 -0
  180. package/canvas-dist/assets/index-iQWajCow.js +572 -0
  181. package/canvas-dist/assets/index-m68YlAMU.js +11 -0
  182. package/canvas-dist/assets/index-mEoP57az.js +11 -0
  183. package/canvas-dist/assets/{infoDiagram-HS3SLOUP-D1Kg3Q9d.js → infoDiagram-HS3SLOUP--9BirqgJ.js} +1 -1
  184. package/canvas-dist/assets/infoDiagram-HS3SLOUP-CSJVED2y.js +2 -0
  185. package/canvas-dist/assets/infoDiagram-HS3SLOUP-D68HIb2t.js +2 -0
  186. package/canvas-dist/assets/infoDiagram-HS3SLOUP-DK2VLGGz.js +2 -0
  187. package/canvas-dist/assets/infoDiagram-HS3SLOUP-PaFhn4yD.js +2 -0
  188. package/canvas-dist/assets/infoDiagram-HS3SLOUP-zLNG47sU.js +2 -0
  189. package/canvas-dist/assets/{journeyDiagram-XKPGCS4Q-D7ogbx9z.js → journeyDiagram-XKPGCS4Q-Bue2dR2X.js} +1 -1
  190. package/canvas-dist/assets/journeyDiagram-XKPGCS4Q-CrgZfpdU.js +139 -0
  191. package/canvas-dist/assets/journeyDiagram-XKPGCS4Q-DUxWmkkC.js +139 -0
  192. package/canvas-dist/assets/journeyDiagram-XKPGCS4Q-OTFkv4pd.js +139 -0
  193. package/canvas-dist/assets/journeyDiagram-XKPGCS4Q-eK2_Zuu3.js +139 -0
  194. package/canvas-dist/assets/journeyDiagram-XKPGCS4Q-uds5Tz8D.js +139 -0
  195. package/canvas-dist/assets/{kanban-definition-3W4ZIXB7-CDcnICM9.js → kanban-definition-3W4ZIXB7-BETdiI7I.js} +1 -1
  196. package/canvas-dist/assets/kanban-definition-3W4ZIXB7-BdVh7KdN.js +89 -0
  197. package/canvas-dist/assets/kanban-definition-3W4ZIXB7-Cxl8UM9S.js +89 -0
  198. package/canvas-dist/assets/kanban-definition-3W4ZIXB7-DVPlx3I2.js +89 -0
  199. package/canvas-dist/assets/kanban-definition-3W4ZIXB7-LtNWeoYB.js +89 -0
  200. package/canvas-dist/assets/kanban-definition-3W4ZIXB7-uvhEMvyE.js +89 -0
  201. package/canvas-dist/assets/{layout-CuaK7i3M.js → layout-1OzszN14.js} +1 -1
  202. package/canvas-dist/assets/layout-CJSupFcF.js +1 -0
  203. package/canvas-dist/assets/layout-DFRmxN_c.js +1 -0
  204. package/canvas-dist/assets/layout-DSu-zk7y.js +1 -0
  205. package/canvas-dist/assets/layout-TGcrvApd.js +1 -0
  206. package/canvas-dist/assets/layout-eStc8SYK.js +1 -0
  207. package/canvas-dist/assets/{linear-CLSTOJ0g.js → linear-9qlE6xa7.js} +1 -1
  208. package/canvas-dist/assets/linear-CBfFWnLD.js +1 -0
  209. package/canvas-dist/assets/linear-Cv4ai8Hq.js +1 -0
  210. package/canvas-dist/assets/linear-DDzz65E6.js +1 -0
  211. package/canvas-dist/assets/linear-wbIqhwDf.js +1 -0
  212. package/canvas-dist/assets/linear-wyNKl76F.js +1 -0
  213. package/canvas-dist/assets/{mindmap-definition-VGOIOE7T-TrK7CIKt.js → mindmap-definition-VGOIOE7T-3l4YzhEM.js} +1 -1
  214. package/canvas-dist/assets/mindmap-definition-VGOIOE7T-B-KkpNlw.js +68 -0
  215. package/canvas-dist/assets/mindmap-definition-VGOIOE7T-DHMHWgmT.js +68 -0
  216. package/canvas-dist/assets/mindmap-definition-VGOIOE7T-Dqfyg4Z2.js +68 -0
  217. package/canvas-dist/assets/mindmap-definition-VGOIOE7T-NeRYOzsq.js +68 -0
  218. package/canvas-dist/assets/mindmap-definition-VGOIOE7T-xyu628P9.js +68 -0
  219. package/canvas-dist/assets/{pieDiagram-ADFJNKIX-BcIKTRbi.js → pieDiagram-ADFJNKIX-BWNzVAGj.js} +1 -1
  220. package/canvas-dist/assets/pieDiagram-ADFJNKIX-Bm3PXYs-.js +30 -0
  221. package/canvas-dist/assets/pieDiagram-ADFJNKIX-BvvN7VvQ.js +30 -0
  222. package/canvas-dist/assets/pieDiagram-ADFJNKIX-BwU7AN7W.js +30 -0
  223. package/canvas-dist/assets/pieDiagram-ADFJNKIX-CHgwWCaM.js +30 -0
  224. package/canvas-dist/assets/pieDiagram-ADFJNKIX-DlZc8YOh.js +30 -0
  225. package/canvas-dist/assets/{quadrantDiagram-AYHSOK5B-EOHXFGoQ.js → quadrantDiagram-AYHSOK5B-B-Zd8OFp.js} +1 -1
  226. package/canvas-dist/assets/quadrantDiagram-AYHSOK5B-B1CnJyxI.js +7 -0
  227. package/canvas-dist/assets/quadrantDiagram-AYHSOK5B-C0Qo00b9.js +7 -0
  228. package/canvas-dist/assets/quadrantDiagram-AYHSOK5B-C9bx3nEJ.js +7 -0
  229. package/canvas-dist/assets/quadrantDiagram-AYHSOK5B-UHENkiRO.js +7 -0
  230. package/canvas-dist/assets/quadrantDiagram-AYHSOK5B-jKfurTPU.js +7 -0
  231. package/canvas-dist/assets/{requirementDiagram-UZGBJVZJ-CJ8lImGs.js → requirementDiagram-UZGBJVZJ-BPpNNusD.js} +1 -1
  232. package/canvas-dist/assets/requirementDiagram-UZGBJVZJ-BwZF1NIK.js +64 -0
  233. package/canvas-dist/assets/requirementDiagram-UZGBJVZJ-CaT3Frtk.js +64 -0
  234. package/canvas-dist/assets/requirementDiagram-UZGBJVZJ-Dfoz7R_7.js +64 -0
  235. package/canvas-dist/assets/requirementDiagram-UZGBJVZJ-DsrX4TT-.js +64 -0
  236. package/canvas-dist/assets/requirementDiagram-UZGBJVZJ-dmouSXOl.js +64 -0
  237. package/canvas-dist/assets/{sankeyDiagram-TZEHDZUN-4cANY87E.js → sankeyDiagram-TZEHDZUN-BEy-A1Fu.js} +1 -1
  238. package/canvas-dist/assets/sankeyDiagram-TZEHDZUN-BViMBiAQ.js +10 -0
  239. package/canvas-dist/assets/sankeyDiagram-TZEHDZUN-BqrM-qWN.js +10 -0
  240. package/canvas-dist/assets/sankeyDiagram-TZEHDZUN-DRkRC9qB.js +10 -0
  241. package/canvas-dist/assets/sankeyDiagram-TZEHDZUN-DbuzKCtn.js +10 -0
  242. package/canvas-dist/assets/sankeyDiagram-TZEHDZUN-_aHMKbpw.js +10 -0
  243. package/canvas-dist/assets/{sequenceDiagram-WL72ISMW-D9HrEsci.js → sequenceDiagram-WL72ISMW-B8FOaL2Q.js} +1 -1
  244. package/canvas-dist/assets/sequenceDiagram-WL72ISMW-C02NQwOB.js +145 -0
  245. package/canvas-dist/assets/sequenceDiagram-WL72ISMW-CgyHivPj.js +145 -0
  246. package/canvas-dist/assets/sequenceDiagram-WL72ISMW-CzW1WaEm.js +145 -0
  247. package/canvas-dist/assets/sequenceDiagram-WL72ISMW-DJhHI1pe.js +145 -0
  248. package/canvas-dist/assets/sequenceDiagram-WL72ISMW-VFkpAeoG.js +145 -0
  249. package/canvas-dist/assets/{stateDiagram-FKZM4ZOC-qVbMjauZ.js → stateDiagram-FKZM4ZOC-BSqFX4PJ.js} +1 -1
  250. package/canvas-dist/assets/stateDiagram-FKZM4ZOC-BnXhhxkN.js +1 -0
  251. package/canvas-dist/assets/stateDiagram-FKZM4ZOC-ClARVrvt.js +1 -0
  252. package/canvas-dist/assets/stateDiagram-FKZM4ZOC-CuC6xesY.js +1 -0
  253. package/canvas-dist/assets/stateDiagram-FKZM4ZOC-DcAiGjph.js +1 -0
  254. package/canvas-dist/assets/stateDiagram-FKZM4ZOC-aBg0hjTp.js +1 -0
  255. package/canvas-dist/assets/stateDiagram-v2-4FDKWEC3-8fib9ftc.js +1 -0
  256. package/canvas-dist/assets/stateDiagram-v2-4FDKWEC3-B-DO0ZqO.js +1 -0
  257. package/canvas-dist/assets/stateDiagram-v2-4FDKWEC3-BksbsE4k.js +1 -0
  258. package/canvas-dist/assets/stateDiagram-v2-4FDKWEC3-C2DJCNPK.js +1 -0
  259. package/canvas-dist/assets/stateDiagram-v2-4FDKWEC3-CeA5jba6.js +1 -0
  260. package/canvas-dist/assets/stateDiagram-v2-4FDKWEC3-zsAyq0tK.js +1 -0
  261. package/canvas-dist/assets/{timeline-definition-IT6M3QCI-DDBlkydm.js → timeline-definition-IT6M3QCI-BaHdYD2h.js} +1 -1
  262. package/canvas-dist/assets/timeline-definition-IT6M3QCI-Bl2hg8IM.js +61 -0
  263. package/canvas-dist/assets/timeline-definition-IT6M3QCI-CrVwLiGm.js +61 -0
  264. package/canvas-dist/assets/timeline-definition-IT6M3QCI-DrXGRjnB.js +61 -0
  265. package/canvas-dist/assets/timeline-definition-IT6M3QCI-cYAwshf6.js +61 -0
  266. package/canvas-dist/assets/timeline-definition-IT6M3QCI-flyL0y-3.js +61 -0
  267. package/canvas-dist/assets/{treemap-GDKQZRPO-D4a8udjO.js → treemap-GDKQZRPO-C4Hg8kJ_.js} +1 -1
  268. package/canvas-dist/assets/treemap-GDKQZRPO-DVY2G9qY.js +162 -0
  269. package/canvas-dist/assets/treemap-GDKQZRPO-DpLWPA1z.js +162 -0
  270. package/canvas-dist/assets/treemap-GDKQZRPO-Ds86cUVw.js +162 -0
  271. package/canvas-dist/assets/treemap-GDKQZRPO-DwmoI6tH.js +162 -0
  272. package/canvas-dist/assets/treemap-GDKQZRPO-SsGFkgVd.js +162 -0
  273. package/canvas-dist/assets/{xychartDiagram-PRI3JC2R-DteXAAAu.js → xychartDiagram-PRI3JC2R-B9c1iLBf.js} +1 -1
  274. package/canvas-dist/assets/xychartDiagram-PRI3JC2R-BpX6MPWa.js +7 -0
  275. package/canvas-dist/assets/xychartDiagram-PRI3JC2R-CEgW_j0p.js +7 -0
  276. package/canvas-dist/assets/xychartDiagram-PRI3JC2R-CSEFGEQX.js +7 -0
  277. package/canvas-dist/assets/xychartDiagram-PRI3JC2R-CnG4XoMc.js +7 -0
  278. package/canvas-dist/assets/xychartDiagram-PRI3JC2R-Dftj3Bt3.js +7 -0
  279. package/canvas-dist/index.html +3 -2
  280. package/package.json +4 -2
  281. package/src/protocol.js +1 -1
  282. package/src/protocol.test.js +1 -1
  283. package/src/pty-manager.js +281 -0
  284. package/src/pty-manager.test.js +212 -0
  285. package/src/router.js +31 -1
  286. package/src/router.test.js +1 -1
  287. package/src/sdk-e2e.test.js +101 -0
  288. package/src/server.e2e.test.js +812 -177
  289. package/src/server.js +1516 -264
  290. package/src/session-store.js +260 -0
  291. package/src/session-store.test.js +235 -0
  292. package/src/templates.js +1 -1
  293. package/src/templates.test.js +1 -1
  294. package/src/terminal.js +3 -3
  295. package/src/terminal.test.js +12 -12
  296. package/src/visual-interceptor.js +450 -0
  297. package/src/visual-interceptor.test.js +943 -0
  298. package/src/workshop-parser.js +251 -0
  299. package/src/workshop-parser.test.js +179 -0
  300. package/templates/celebrate.html +1 -1
  301. package/templates/code-playground.html +1 -1
  302. package/templates/dashboard.html +7 -8
  303. package/templates/diagram-architecture.html +1 -1
  304. package/templates/diagram-flow.html +1 -1
  305. package/templates/diagram-mermaid.html +1 -1
  306. package/templates/game-speed-round.html +1 -1
  307. package/templates/quiz-drag-order.html +1 -1
  308. package/templates/quiz-fill-blank.html +1 -1
  309. package/templates/quiz-matching.html +1 -1
  310. package/templates/quiz-timed-choice.html +1 -1
  311. package/templates/welcome.html +7 -7
  312. package/canvas-dist/assets/channel-saCUO1KA.js +0 -1
  313. package/canvas-dist/assets/classDiagram-2ON5EDUG-CBLbQwHx.js +0 -1
  314. package/canvas-dist/assets/classDiagram-v2-WZHVMYZB-CBLbQwHx.js +0 -1
  315. package/canvas-dist/assets/clone-DXnda9BY.js +0 -1
  316. package/canvas-dist/assets/index-DYNtb52W.js +0 -426
  317. package/canvas-dist/assets/stateDiagram-v2-4FDKWEC3-MT16RLO4.js +0 -1
  318. package/src/claude-session.js +0 -414
  319. package/src/claude-session.test.js +0 -326
package/src/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ClaudeTeach Bridge Server — WebSocket + SSE + REST communication hub.
2
+ * Socrates Bridge Server — WebSocket + SSE + REST communication hub.
3
3
  * Upgraded from packages/core/src/bridge-server.js.
4
4
  *
5
5
  * Endpoints:
@@ -21,7 +21,7 @@
21
21
  */
22
22
 
23
23
  import { createServer } from 'http';
24
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
24
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, createReadStream } from 'fs';
25
25
  import { join, dirname, extname, resolve } from 'path';
26
26
  import { fileURLToPath } from 'url';
27
27
  import { exec } from 'child_process';
@@ -34,16 +34,37 @@ import {
34
34
  serializeEnvelope,
35
35
  MSG_SYS_CONNECT,
36
36
  MSG_SYS_DISCONNECT,
37
+ MSG_EVENT_PTY_OUTPUT,
37
38
  DEFAULT_PORT,
38
39
  INBOX_DIR,
39
40
  MAX_LONG_POLL_TIMEOUT_MS,
41
+ ACHIEVEMENTS,
42
+ BELTS,
43
+ PLAYGROUND_EXEC_TIMEOUT_MS,
44
+ PLAYGROUND_MAX_OUTPUT_BYTES,
40
45
  } from '@shaykec/shared';
41
46
 
42
47
  import { TierManager, generateClientId, validateHandshake } from './protocol.js';
43
48
  import { EventRouter } from './router.js';
44
49
  import { renderTemplate, listTemplates } from './templates.js';
45
50
  import { createSession, execCommand, getSession, destroySession, cleanupIdleSessions } from './terminal.js';
46
- import { ClaudeSessionManager } from './claude-session.js';
51
+ import {
52
+ createPtySession, writeToPty, resizePty, setPtyCallbacks,
53
+ hasPtySession, destroyPtySession, writeSessionFiles, runSetupCommands,
54
+ cleanupIdlePtySessions, getPtyOutputBuffer,
55
+ } from './pty-manager.js';
56
+ import { extractAndRouteVisuals, extractAndRouteMedia, extractAndRouteSuggestions, extractButtons, extractInlineBlocks, enhanceWithSmartComponents, stripCanvasCommands } from './visual-interceptor.js';
57
+ import { createAgentServer } from '@shaykec/agent-web/server';
58
+ import {
59
+ listSessions as listStoredSessions,
60
+ getSession as getStoredSession,
61
+ appendMessage as appendStoredMessage,
62
+ updateMeta as updateStoredMeta,
63
+ deleteSession as deleteStoredSession,
64
+ purgeAll as purgeStoredSessions,
65
+ detectSessionTag,
66
+ generateTitle,
67
+ } from './session-store.js';
47
68
 
48
69
  const __filename = fileURLToPath(import.meta.url);
49
70
  const __dirname = dirname(__filename);
@@ -72,22 +93,230 @@ const MIME_TYPES = {
72
93
  };
73
94
 
74
95
  /**
75
- * Start the ClaudeTeach bridge server.
96
+ * Start the Socrates bridge server.
76
97
  * @param {object} [options]
77
98
  * @param {number} [options.port=3456] - Port number
78
99
  * @param {function} [options.onReady] - Callback when server is listening
79
100
  * @param {function} [options.onTierChange] - Callback(oldTier, newTier) on tier changes
80
- * @param {object} [options.progressProvider] - Object with getProgress() method
101
+ * @param {object} [options.progressProvider] - Object with getProgress() and saveProgress() methods
81
102
  * @returns {{ server: object, tierManager: TierManager, router: EventRouter, close: function }}
82
103
  */
83
104
  export function startServer(options = {}) {
84
105
  const port = options.port || DEFAULT_PORT;
85
106
  const tierManager = new TierManager();
86
107
  const router = new EventRouter(tierManager);
87
- const chatSession = new ClaudeSessionManager();
88
108
 
89
- // Maps sessionId -> clientId for targeted chat message delivery
90
- const sessionClientMap = new Map();
109
+ // Chat is delegated entirely to @shaykec/agent-web
110
+ const agentConfig = {
111
+ cwd: process.cwd(),
112
+ systemPrompt: [
113
+ 'You are Socrates, a Socratic AI tutor. You teach through guided dialogue.',
114
+ '',
115
+ 'RULES:',
116
+ '- Ask, don\'t tell. Guide the user to discover answers through questions.',
117
+ '- The user writes all code. You guide, they do.',
118
+ '- One step at a time. Award XP after milestones.',
119
+ '',
120
+ 'ENVIRONMENT:',
121
+ '- The user is in a web app with a visual canvas beside the chat.',
122
+ '- A bridge server at http://localhost:3456 has visual tools.',
123
+ '- When a user sends /teach <topic>, use the Skill tool to read the "teach" skill, then follow its instructions.',
124
+ '- When a user sends /learn <topic>, use the Skill tool to read the "learn" skill, then follow its instructions.',
125
+ '- For /stats, /ask, /canvas, /level-up — use the Skill tool to read the matching skill name.',
126
+ '- For casual questions, answer helpfully. Include mermaid diagrams when useful —',
127
+ ' they auto-appear on the visual canvas.',
128
+ '',
129
+ 'MANDATORY — RICH COMPONENTS IN EVERY TEACHING RESPONSE:',
130
+ 'You MUST use the smart components below in EVERY teaching response. Plain markdown alone is NOT acceptable.',
131
+ 'Each teaching response MUST include at least 3 of these components. The canvas renders them as interactive UI.',
132
+ '',
133
+ 'SMART BUTTONS (REQUIRED for questions and choices):',
134
+ '- ALWAYS use buttons when asking the user a question or presenting choices.',
135
+ '- Embed inline buttons using an HTML comment:',
136
+ ' <!-- buttons: {"id":"unique-id","type":"single","prompt":"Pick one:","options":[{"label":"Option A","value":"text sent when clicked"}]} -->',
137
+ '- Types: "single" (pick one), "multi" (pick several + submit), "rating" (1-5 scale).',
138
+ '- Each option needs "label" (display text) and "value" (message sent on click).',
139
+ '- Use buttons for contextual choices (topic selection, difficulty rating, yes/no).',
140
+ '- Use suggestion chips (<!-- suggestions: [...] -->) for general navigation.',
141
+ '',
142
+ 'SMART LISTS (REQUIRED for any list of items):',
143
+ '- NEVER use plain markdown lists. ALWAYS use smart lists instead:',
144
+ ' <!-- list: {"id":"x","style":"cards","items":[{"icon":"book","title":"T","description":"D","action":"msg"}]} -->',
145
+ '- Styles: "cards" (rich cards with icons), "numbered" (ordered steps), "checklist" (checkable items), "compact" (dense).',
146
+ '- Items with "action" are clickable and send the action text as a message.',
147
+ '',
148
+ 'PROGRESS BARS (REQUIRED to show lesson progress):',
149
+ '- Include a progress bar in EVERY teaching response to show where the user is in the lesson:',
150
+ ' <!-- progress: {"id":"lesson-progress","label":"Lesson Progress","current":1,"total":7,"style":"bar"} -->',
151
+ '- Styles: "bar" (horizontal), "steps" (step indicators), "ring" (circular).',
152
+ '- Update the current value as the user progresses through steps.',
153
+ '',
154
+ 'INFO CARDS (REQUIRED for tips, warnings, and key concepts):',
155
+ '- Use info cards for important callouts — NEVER use plain bold text for tips or warnings:',
156
+ ' <!-- card: {"id":"x","type":"tip","title":"Pro Tip","content":"markdown text here"} -->',
157
+ '- Types: "tip" (blue), "warning" (yellow), "error" (red), "success" (green), "concept" (purple).',
158
+ '- Use "concept" for key definitions, "tip" for best practices, "warning" for common mistakes.',
159
+ '',
160
+ 'CODE SNIPPETS (use when showing code examples):',
161
+ ' <!-- code: {"id":"x","language":"js","filename":"app.js","code":"const x = 1;","highlight":[1]} -->',
162
+ '',
163
+ 'STEP TRACKERS (use to show multi-step workflows):',
164
+ ' <!-- steps: {"id":"x","current":2,"steps":[{"label":"Create file","status":"done"},{"label":"Write code","status":"active"},{"label":"Test","status":"pending"}]} -->',
165
+ ' Statuses: done, active, pending.',
166
+ '',
167
+ 'CANVAS VISUAL COMMANDS (appear in the canvas pane, not in the chat bubble):',
168
+ '- Diagrams: <!-- canvas:diagram: {"format":"mermaid","content":"graph TD\\n A-->B","animate":true} -->',
169
+ ' Or use a fenced ```mermaid code block — it auto-routes to the canvas.',
170
+ '- Celebrations: <!-- canvas:celebrate: {"type":"xp","xpAwarded":20} -->',
171
+ ' Types: "xp" (confetti), "level-up" (include "newBelt":"green"), "perfect-score".',
172
+ '- Rich HTML: <!-- canvas:html: {"html":"<div>...</div>"} -->',
173
+ ' Use for images, YouTube embeds, further-reading cards.',
174
+ '- Practice terminal: <!-- canvas:terminal: {"task":"Create a git repo","validations":[{"label":"Repo created","passed":false}],"hints":["Use git init"]} -->',
175
+ '- Dashboard: <!-- canvas:dashboard: {"progress":{"xp":100,"belt":"yellow","modules":[...]}} -->',
176
+ '- Web embed: <!-- canvas:web-embed: {"url":"https://...","title":"Reference"} -->',
177
+ '- Code playground: <!-- canvas:code: {"language":"javascript","code":"function add(a,b) {...}","tests":[...]} -->',
178
+ '- These comments are stripped from the displayed text — the user sees only the rendered component.',
179
+ '- For interactive quizzes and games that need the user\'s answer back, use curl with await (read the skill for details).',
180
+ '',
181
+ 'COMPONENT ORDER (follow this layout in every teaching response):',
182
+ '1. Step tracker / Progress bar — orientation elements go FIRST',
183
+ '2. Teaching text, concept cards, lists, diagrams, code snippets — content in the middle',
184
+ '3. Question + buttons — interactive prompt goes LAST (just before suggestions)',
185
+ '4. Suggestion chips — always the very last element',
186
+ 'Never place a step tracker or progress bar after a question or buttons.',
187
+ '',
188
+ 'EXAMPLE TEACHING RESPONSE (follow this pattern):',
189
+ '```',
190
+ '<!-- progress: {"id":"p1","label":"Lesson Progress","current":2,"total":5,"style":"bar"} -->',
191
+ '',
192
+ 'Great question! Let\'s explore **variables** in JavaScript.',
193
+ '',
194
+ '<!-- card: {"id":"concept1","type":"concept","title":"What is a Variable?","content":"A variable is a named container that stores a value. Think of it as a labeled box."} -->',
195
+ '',
196
+ 'There are three ways to declare variables:',
197
+ '',
198
+ '<!-- list: {"id":"var-types","style":"cards","items":[{"icon":"📦","title":"const","description":"Cannot be reassigned. Use by default."},{"icon":"🔄","title":"let","description":"Can be reassigned. Use for values that change."},{"icon":"⚠️","title":"var","description":"Old syntax. Avoid in modern code."}]} -->',
199
+ '',
200
+ '<!-- card: {"id":"tip1","type":"tip","title":"Best Practice","content":"Always start with `const`. Only switch to `let` if you need to reassign."} -->',
201
+ '',
202
+ '<!-- canvas:diagram: {"format":"mermaid","content":"graph LR\\n const[const] -->|immutable| safe[Safe default]\\n let[let] -->|mutable| change[Values that change]\\n var[var] -->|avoid| legacy[Legacy code]"} -->',
203
+ '',
204
+ 'Which declaration would you use for a user\'s age that changes every year?',
205
+ '',
206
+ '<!-- buttons: {"id":"q1","type":"single","prompt":"Pick the best option:","options":[{"label":"const","value":"I would use const for age"},{"label":"let","value":"I would use let for age"},{"label":"var","value":"I would use var for age"}]} -->',
207
+ '',
208
+ '<!-- suggestions: [{"label":"Show me an example","text":"Show me a code example"},{"label":"Quiz me","text":"Give me a quiz"}] -->',
209
+ '```',
210
+ '',
211
+ 'START: /teach <topic> for lessons, /learn <topic> for free-form learning.',
212
+ ].join('\n'),
213
+ tools: ['Bash(*)', 'Read', 'Write', 'Edit', 'Glob', 'Grep', 'Skill'],
214
+ permissionMode: 'bypassPermissions',
215
+ settingSources: ['user', 'project'],
216
+ };
217
+ if (options.pluginDir) {
218
+ agentConfig.plugins = [{ type: 'local', path: options.pluginDir }];
219
+ }
220
+ const autoRoutedMediaUrls = new Set();
221
+ const sessionTitleSet = new Set();
222
+
223
+ const agentServer = createAgentServer({
224
+ basePath: '/api',
225
+ config: agentConfig,
226
+ hooks: {
227
+ onMessage: (envelope) => {
228
+ if (envelope.type === 'chat:assistant' && envelope.payload?.text) {
229
+ // Auto-enhance plain markdown with smart components when the AI
230
+ // didn't emit them. Must run before extraction so the injected
231
+ // HTML comments get picked up by extractButtons/extractInlineBlocks.
232
+ envelope.payload.text = enhanceWithSmartComponents(envelope.payload.text);
233
+
234
+ const { cleanText, buttons } = extractButtons(envelope.payload.text);
235
+ if (buttons.length > 0) {
236
+ envelope.payload.text = cleanText;
237
+ envelope.payload.buttons = buttons;
238
+ }
239
+ const { cleanText: text2, blocks } = extractInlineBlocks(envelope.payload.text);
240
+ if (blocks.length > 0) {
241
+ envelope.payload.text = text2;
242
+ envelope.payload.blocks = blocks;
243
+ }
244
+ }
245
+
246
+ if (envelope.type === 'chat:assistant' && envelope.payload?.text) {
247
+ const rawText = envelope.payload.text;
248
+ extractAndRouteVisuals(rawText, tierManager, router);
249
+ extractAndRouteMedia(rawText, router, autoRoutedMediaUrls);
250
+ extractAndRouteSuggestions(rawText, tierManager);
251
+ envelope.payload.text = stripCanvasCommands(envelope.payload.text);
252
+ }
253
+
254
+ const data = JSON.stringify(envelope);
255
+ tierManager.broadcastWs(data);
256
+ tierManager.broadcastSse(data);
257
+
258
+ // Persist messages to session store
259
+ const sid = envelope.sessionId || envelope.payload?.sessionId;
260
+ if (sid) {
261
+ try {
262
+ if (envelope.type === 'chat:assistant' && envelope.payload?.text) {
263
+ appendStoredMessage(sid, {
264
+ role: 'assistant',
265
+ text: envelope.payload.text,
266
+ timestamp: envelope.timestamp || Date.now(),
267
+ });
268
+ // Auto-title: set title from first assistant response
269
+ if (!sessionTitleSet.has(sid)) {
270
+ sessionTitleSet.add(sid);
271
+ const title = generateTitle(envelope.payload.text);
272
+ updateStoredMeta(sid, { title });
273
+ }
274
+ } else if (envelope.type === 'chat:tool-use') {
275
+ appendStoredMessage(sid, {
276
+ role: 'tool-use',
277
+ text: `Using ${envelope.payload?.toolName || 'tool'}`,
278
+ data: envelope.payload,
279
+ timestamp: envelope.timestamp || Date.now(),
280
+ });
281
+ } else if (envelope.type === 'chat:tool-result') {
282
+ appendStoredMessage(sid, {
283
+ role: 'tool-result',
284
+ data: envelope.payload,
285
+ timestamp: envelope.timestamp || Date.now(),
286
+ });
287
+ }
288
+ } catch { /* don't break chat flow on storage errors */ }
289
+ }
290
+ },
291
+ },
292
+ });
293
+ const chatMiddleware = agentServer.middleware();
294
+
295
+ // Patch: escape leading '/' in messages to prevent SDK slash command interception.
296
+ // The Claude Agent SDK intercepts messages starting with '/' as built-in slash commands
297
+ // (e.g., /debug, /review). Our custom skills (/teach, /learn, etc.) aren't registered
298
+ // with the SDK, so they get "Unknown skill" errors. Prepending a zero-width space
299
+ // bypasses the SDK's slash handler while preserving the text for Claude.
300
+ const sm = agentServer.sessions;
301
+ const origSend = sm.sendMessage.bind(sm);
302
+ sm.sendMessage = function(sessionId, text) {
303
+ // Persist user message + auto-tag session
304
+ try {
305
+ appendStoredMessage(sessionId, {
306
+ role: 'user',
307
+ text,
308
+ timestamp: Date.now(),
309
+ });
310
+ const tag = detectSessionTag(text);
311
+ if (tag) {
312
+ updateStoredMeta(sessionId, tag);
313
+ sessionTitleSet.add(sessionId);
314
+ }
315
+ } catch { /* don't break chat flow */ }
316
+
317
+ const escaped = text.startsWith('/') ? '\u200B' + text : text;
318
+ return origSend(sessionId, escaped);
319
+ };
91
320
 
92
321
  if (options.onTierChange) {
93
322
  tierManager.onTierChange(options.onTierChange);
@@ -96,47 +325,107 @@ export function startServer(options = {}) {
96
325
  // Ensure inbox directory exists
97
326
  ensureDir(INBOX_DIR);
98
327
 
99
- /**
100
- * Create an onMessage callback that sends chat envelopes to a specific client.
101
- * Falls back to broadcast if the client disconnected.
102
- * @param {string} clientId
103
- * @returns {function}
104
- */
105
- function createTargetedSend(clientId) {
106
- return (envelope) => {
107
- const data = JSON.stringify(envelope);
108
- if (!tierManager.sendToClient(clientId, data)) {
109
- // Client disconnected — broadcast as fallback
110
- tierManager.broadcastWs(data);
111
- tierManager.broadcastSse(data);
112
- }
113
- };
114
- }
328
+ // Create HTTP server
329
+ const server = createServer((req, res) => {
330
+ handleRequest(req, res, tierManager, router, options, chatMiddleware, agentServer);
331
+ });
115
332
 
116
- // Wire up chat messages from browser -> session manager
117
- router.onChatMessage = (envelope) => {
118
- if (envelope.type === 'chat:send' && envelope.payload?.text) {
119
- const sessionId = envelope.payload.sessionId;
120
- if (!sessionId || !chatSession.isActive(sessionId)) return;
121
- chatSession.sendMessage(sessionId, envelope.payload.text).catch((err) => {
122
- console.error('[chat] sendMessage error:', err.message);
333
+ // Both WSSes use noServer to avoid ws library destroying sockets
334
+ // on path mismatch (ws docs: use noServer for multiple WSSes)
335
+ const wss = new WebSocketServer({ noServer: true });
336
+ const chatWss = new WebSocketServer({ noServer: true });
337
+ const ptyWss = new WebSocketServer({ noServer: true });
338
+
339
+ server.on('upgrade', (req, socket, head) => {
340
+ const { pathname } = new URL(req.url, `http://${req.headers.host}`);
341
+ if (pathname === '/ws') {
342
+ wss.handleUpgrade(req, socket, head, (ws) => {
343
+ wss.emit('connection', ws, req);
344
+ });
345
+ } else if (pathname === '/api/ws') {
346
+ chatWss.handleUpgrade(req, socket, head, (ws) => {
347
+ chatWss.emit('connection', ws, req);
348
+ });
349
+ } else if (pathname.startsWith('/ws/terminal/')) {
350
+ ptyWss.handleUpgrade(req, socket, head, (ws) => {
351
+ const sessionId = pathname.replace('/ws/terminal/', '');
352
+ ptyWss.emit('connection', ws, sessionId);
123
353
  });
354
+ } else {
355
+ socket.destroy();
124
356
  }
125
- if (envelope.type === 'chat:stop') {
126
- const sessionId = envelope.payload?.sessionId;
127
- if (sessionId) {
128
- chatSession.stop(sessionId).catch(() => {});
129
- }
357
+ });
358
+
359
+ // PTY terminal WebSocket connections
360
+ ptyWss.on('connection', (ws, sessionId) => {
361
+ if (!hasPtySession(sessionId)) {
362
+ ws.close(4004, 'Unknown PTY session');
363
+ return;
130
364
  }
131
- };
132
365
 
133
- // Create HTTP server
134
- const server = createServer((req, res) => {
135
- handleRequest(req, res, tierManager, router, options, chatSession, sessionClientMap, createTargetedSend);
366
+ setPtyCallbacks(sessionId,
367
+ (data) => {
368
+ if (ws.readyState === 1) {
369
+ ws.send(JSON.stringify({ type: 'data', data }));
370
+ }
371
+ // Tee output to agent event queue for observation
372
+ router.enqueuePtyEvent(sessionId, data);
373
+ },
374
+ ({ exitCode, signal }) => {
375
+ if (ws.readyState === 1) {
376
+ ws.send(JSON.stringify({ type: 'exit', exitCode, signal }));
377
+ ws.close(1000, 'PTY exited');
378
+ }
379
+ }
380
+ );
381
+
382
+ ws.on('message', (rawData) => {
383
+ try {
384
+ const msg = JSON.parse(rawData.toString());
385
+ switch (msg.type) {
386
+ case 'input':
387
+ writeToPty(sessionId, msg.data);
388
+ break;
389
+ case 'resize':
390
+ if (msg.cols && msg.rows) {
391
+ resizePty(sessionId, msg.cols, msg.rows);
392
+ }
393
+ break;
394
+ }
395
+ } catch { /* ignore malformed messages */ }
396
+ });
397
+
398
+ ws.on('close', () => {
399
+ // Keep PTY alive for reconnect; cleanup timer handles idle sessions
400
+ });
401
+ });
402
+
403
+ // Wire agent-web transport to the chat WSS
404
+ chatWss.on('connection', (ws) => {
405
+ agentServer.transport.handleWsConnection(
406
+ ws,
407
+ () => {},
408
+ (clientId, envelope) => {
409
+ if (envelope.type === 'chat:send') {
410
+ const sessionId = envelope.sessionId || envelope.payload?.sessionId;
411
+ const text = envelope.payload?.text;
412
+ if (sessionId && text) {
413
+ agentServer.sessions.sendMessage(sessionId, text).catch(err => {
414
+ console.error('[agent-web] sendMessage error:', err.message);
415
+ });
416
+ }
417
+ }
418
+ if (envelope.type === 'chat:stop') {
419
+ const sessionId = envelope.sessionId || envelope.payload?.sessionId;
420
+ if (sessionId) {
421
+ agentServer.sessions.stopSession(sessionId).catch(() => {});
422
+ }
423
+ }
424
+ }
425
+ );
136
426
  });
137
427
 
138
- // Create WebSocket server on the same HTTP server
139
- const wss = new WebSocketServer({ server, path: '/ws' });
428
+ agentServer.transport.startHeartbeat();
140
429
 
141
430
  wss.on('connection', (ws, req) => {
142
431
  const clientId = generateClientId();
@@ -185,7 +474,27 @@ export function startServer(options = {}) {
185
474
  return;
186
475
  }
187
476
 
188
- // After handshake, route messages through the router
477
+ // Intercept chat messages and forward to agent-web session manager
478
+ const { valid: msgValid, envelope: msgEnv } = parseEnvelope(data);
479
+ if (msgValid && msgEnv.type === 'chat:send') {
480
+ const sessionId = msgEnv.sessionId || msgEnv.payload?.sessionId;
481
+ const text = msgEnv.payload?.text;
482
+ if (sessionId && text) {
483
+ agentServer.sessions.sendMessage(sessionId, text).catch(err => {
484
+ console.error('[bridge] chat:send error:', err.message);
485
+ });
486
+ }
487
+ return;
488
+ }
489
+ if (msgValid && msgEnv.type === 'chat:stop') {
490
+ const sessionId = msgEnv.sessionId || msgEnv.payload?.sessionId;
491
+ if (sessionId) {
492
+ agentServer.sessions.stopSession(sessionId).catch(() => {});
493
+ }
494
+ return;
495
+ }
496
+
497
+ // After handshake, route visual/event messages through the router
189
498
  const result = router.handleWsMessage(data, clientId);
190
499
  if (!result.handled && result.error) {
191
500
  ws.send(JSON.stringify({ error: result.error }));
@@ -211,11 +520,14 @@ export function startServer(options = {}) {
211
520
  tierManager.startHeartbeat();
212
521
 
213
522
  // Periodically clean up idle terminal sessions (every 60s)
214
- const terminalCleanupInterval = setInterval(() => cleanupIdleSessions(), 60000);
523
+ const terminalCleanupInterval = setInterval(() => {
524
+ cleanupIdleSessions();
525
+ cleanupIdlePtySessions();
526
+ }, 60000);
215
527
 
216
528
  // Start listening
217
529
  server.listen(port, () => {
218
- console.log(`ClaudeTeach bridge server running on http://localhost:${port}`);
530
+ console.log(`Socrates bridge server running on http://localhost:${port}`);
219
531
  console.log(` WebSocket /ws — bidirectional (extension + canvas)`);
220
532
  console.log(` SSE /sse — server-push (canvas-only)`);
221
533
  console.log(` POST /api/event — receive user events`);
@@ -224,15 +536,16 @@ export function startServer(options = {}) {
224
536
  console.log(` GET /api/progress — progress data`);
225
537
  console.log(` GET /api/events/wait — long-poll for events`);
226
538
  console.log(` POST /api/terminal/exec — execute terminal commands`);
227
- console.log(` POST /api/quiz send quiz + wait for answer`);
228
- console.log(` POST /api/gamesend game + wait for result`);
229
- console.log(` POST /api/terminal/exec execute terminal commands`);
230
- console.log(` POST /api/chat/start start chat session`);
231
- console.log(` POST /api/chat/messagesend chat message`);
232
- console.log(` POST /api/chat/stop stop generation`);
233
- console.log(` POST /api/chat/resume resume chat session`);
234
- console.log(` GET /api/chat/sessions list sessions`);
235
- console.log(` GET /health health check`);
539
+ console.log(` POST /api/canvas unified visual commands (with optional await)`);
540
+ console.log(` POST /api/pty/createcreate PTY terminal session`);
541
+ console.log(` WebSocket /ws/terminal/:id PTY terminal I/O`);
542
+ console.log(` POST /api/playground/exec execute code snippets`);
543
+ console.log(` POST /api/workshop/setupset up workshop scenario`);
544
+ console.log(` REST /api/chat/* chat (via @shaykec/agent-web)`);
545
+ console.log(` REST /api/sessions session management (list/get/update/delete/purge)`);
546
+ console.log(` WebSocket /api/ws chat WebSocket`);
547
+ console.log(` SSE /api/sse chat SSE`);
548
+ console.log(` GET /health — health check`);
236
549
  if (existsSync(CANVAS_DIST)) {
237
550
  console.log(` Static / — canvas app from ${CANVAS_DIST}`);
238
551
  }
@@ -245,24 +558,23 @@ export function startServer(options = {}) {
245
558
 
246
559
  const close = () => {
247
560
  clearInterval(terminalCleanupInterval);
248
- chatSession.closeAll().catch(() => {});
249
- sessionClientMap.clear();
561
+ agentServer.close().catch(() => {});
250
562
  router.cancelWaiters();
251
563
  tierManager.stopHeartbeat();
252
564
  wss.close();
253
565
  server.close();
254
566
  };
255
567
 
256
- return { server, wss, tierManager, router, chatSession, close };
568
+ return { server, wss, tierManager, router, agentServer, close };
257
569
  }
258
570
 
259
571
  /**
260
572
  * Handle HTTP requests.
261
573
  */
262
- function handleRequest(req, res, tierManager, router, options, chatSession, sessionClientMap, createTargetedSend) {
574
+ function handleRequest(req, res, tierManager, router, options, chatMiddleware, agentServer) {
263
575
  // CORS headers
264
576
  res.setHeader('Access-Control-Allow-Origin', '*');
265
- res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
577
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
266
578
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
267
579
 
268
580
  if (req.method === 'OPTIONS') {
@@ -274,7 +586,21 @@ function handleRequest(req, res, tierManager, router, options, chatSession, sess
274
586
  const url = new URL(req.url, `http://${req.headers.host}`);
275
587
  const pathname = url.pathname;
276
588
 
277
- // --- SSE endpoint ---
589
+ // --- Chat endpoints delegated to @shaykec/agent-web ---
590
+ if (pathname.startsWith('/api/chat') || pathname === '/api/sse' || pathname === '/api/health') {
591
+ try {
592
+ chatMiddleware(req, res);
593
+ } catch (err) {
594
+ console.error('[bridge] chatMiddleware sync error:', err.message);
595
+ if (!res.headersSent) {
596
+ res.writeHead(500, { 'Content-Type': 'application/json' });
597
+ res.end(JSON.stringify({ error: 'Internal server error' }));
598
+ }
599
+ }
600
+ return;
601
+ }
602
+
603
+ // --- SSE endpoint (visual/events) ---
278
604
  if (req.method === 'GET' && pathname === '/sse') {
279
605
  handleSse(req, res, tierManager);
280
606
  return;
@@ -301,6 +627,11 @@ function handleRequest(req, res, tierManager, router, options, chatSession, sess
301
627
  return;
302
628
  }
303
629
 
630
+ if (req.method === 'GET' && pathname === '/api/constants') {
631
+ sendJson(res, 200, { belts: BELTS });
632
+ return;
633
+ }
634
+
304
635
  if (req.method === 'GET' && pathname === '/api/events') {
305
636
  handleApiPollEvents(res, router);
306
637
  return;
@@ -316,8 +647,8 @@ function handleRequest(req, res, tierManager, router, options, chatSession, sess
316
647
  return;
317
648
  }
318
649
 
319
- if (req.method === 'POST' && pathname === '/api/visual') {
320
- handleApiVisual(req, res, router);
650
+ if (req.method === 'POST' && pathname === '/api/canvas') {
651
+ handleApiCanvas(req, res, router, url);
321
652
  return;
322
653
  }
323
654
 
@@ -326,18 +657,18 @@ function handleRequest(req, res, tierManager, router, options, chatSession, sess
326
657
  return;
327
658
  }
328
659
 
329
- if (req.method === 'POST' && pathname === '/api/quiz') {
330
- handleApiQuiz(req, res, router, url);
660
+ if (req.method === 'POST' && pathname === '/api/pty/create') {
661
+ handleApiPtyCreate(req, res);
331
662
  return;
332
663
  }
333
664
 
334
- if (req.method === 'POST' && pathname === '/api/game') {
335
- handleApiGame(req, res, router, url);
665
+ if (req.method === 'POST' && pathname === '/api/playground/exec') {
666
+ handleApiPlaygroundExec(req, res);
336
667
  return;
337
668
  }
338
669
 
339
- if (req.method === 'POST' && pathname === '/api/terminal/exec') {
340
- handleApiTerminalExec(req, res, router);
670
+ if (req.method === 'POST' && pathname === '/api/workshop/setup') {
671
+ handleApiWorkshopSetup(req, res, router);
341
672
  return;
342
673
  }
343
674
 
@@ -346,29 +677,85 @@ function handleRequest(req, res, tierManager, router, options, chatSession, sess
346
677
  return;
347
678
  }
348
679
 
349
- // --- Chat API endpoints ---
350
- if (req.method === 'POST' && pathname === '/api/chat/start') {
351
- handleApiChatStart(req, res, chatSession, sessionClientMap, createTargetedSend, options);
680
+ // --- Progress write ---
681
+ if (req.method === 'POST' && pathname === '/api/progress') {
682
+ handleApiProgressWrite(req, res, options, router);
683
+ return;
684
+ }
685
+
686
+ // --- Module static resources (images, videos, HTML snippets) ---
687
+ {
688
+ const resMatch = pathname.match(/^\/api\/module\/([\w-]+)\/resources\/([\w.-]+)$/);
689
+ if (req.method === 'GET' && resMatch) {
690
+ handleApiModuleResource(res, resMatch[1], resMatch[2]);
691
+ return;
692
+ }
693
+ }
694
+
695
+ // --- Module content ---
696
+ if (req.method === 'GET' && pathname.startsWith('/api/module/')) {
697
+ const slug = pathname.replace('/api/module/', '');
698
+ if (slug && !slug.includes('/')) {
699
+ handleApiModuleContent(res, slug);
700
+ return;
701
+ }
702
+ }
703
+
704
+ // --- Modules catalog ---
705
+ if (req.method === 'GET' && pathname === '/api/modules') {
706
+ handleApiModules(res);
707
+ return;
708
+ }
709
+
710
+ // --- Journeys ---
711
+ if (req.method === 'GET' && pathname === '/api/journeys') {
712
+ handleApiJourneys(res, options);
352
713
  return;
353
714
  }
354
715
 
355
- if (req.method === 'POST' && pathname === '/api/chat/message') {
356
- handleApiChatMessage(req, res, chatSession);
716
+ if (req.method === 'POST' && pathname === '/api/journeys/activate') {
717
+ handleApiJourneyActivate(req, res, options);
357
718
  return;
358
719
  }
359
720
 
360
- if (req.method === 'POST' && pathname === '/api/chat/stop') {
361
- handleApiChatStop(req, res, chatSession);
721
+ // --- Sessions ---
722
+ if (req.method === 'GET' && pathname === '/api/sessions') {
723
+ handleApiSessionsList(res);
724
+ return;
725
+ }
726
+
727
+ {
728
+ const sessionMatch = pathname.match(/^\/api\/sessions\/([a-f0-9-]+)$/);
729
+ if (sessionMatch) {
730
+ const sid = sessionMatch[1];
731
+ if (req.method === 'GET') {
732
+ handleApiSessionGet(res, sid);
733
+ return;
734
+ }
735
+ if (req.method === 'PUT') {
736
+ handleApiSessionUpdate(req, res, sid);
737
+ return;
738
+ }
739
+ if (req.method === 'DELETE') {
740
+ handleApiSessionDelete(res, sid, agentServer);
741
+ return;
742
+ }
743
+ }
744
+ }
745
+
746
+ if (req.method === 'DELETE' && pathname === '/api/sessions') {
747
+ handleApiSessionsPurge(res, agentServer);
362
748
  return;
363
749
  }
364
750
 
365
- if (req.method === 'POST' && pathname === '/api/chat/resume') {
366
- handleApiChatResume(req, res, chatSession, sessionClientMap, createTargetedSend, options);
751
+ // --- Settings ---
752
+ if (req.method === 'GET' && pathname === '/api/settings') {
753
+ handleApiSettingsGet(res);
367
754
  return;
368
755
  }
369
756
 
370
- if (req.method === 'GET' && pathname === '/api/chat/sessions') {
371
- handleApiChatSessions(res, chatSession);
757
+ if (req.method === 'POST' && pathname === '/api/settings') {
758
+ handleApiSettingsPost(req, res);
372
759
  return;
373
760
  }
374
761
 
@@ -481,176 +868,817 @@ function handleApiCapture(req, res) {
481
868
  }
482
869
 
483
870
  /**
484
- * GET /api/progress — return progress data.
871
+ * GET /api/progress — return progress data with achievements.
872
+ * Enriches module entries with title/description/difficulty from module.yaml catalog.
485
873
  */
486
874
  function handleApiProgress(res, options) {
875
+ let progress;
487
876
  if (options.progressProvider && typeof options.progressProvider.getProgress === 'function') {
488
- const progress = options.progressProvider.getProgress();
489
- sendJson(res, 200, progress);
877
+ progress = options.progressProvider.getProgress();
490
878
  } else {
491
- sendJson(res, 200, { user: { xp: 0 }, modules: {} });
879
+ progress = { user: { xp: 0 }, modules: {} };
492
880
  }
881
+
882
+ // Enrich modules with catalog metadata
883
+ const modulesDir = resolve(__dirname, '..', '..', 'modules');
884
+ const moduleMap = buildModuleMap(modulesDir);
885
+ progress.modules = enrichModulesWithMetadata(progress.modules, moduleMap);
886
+
887
+ // Include achievements data from progress file
888
+ const achievements = progress.achievements || { unlocked: [], progress: {} };
889
+ const unlocked = (achievements.unlocked || []).map(a => ({
890
+ id: a.id,
891
+ date: a.date,
892
+ trigger: a.trigger,
893
+ ...a,
894
+ }));
895
+
896
+ // Build near-locked list from progress counters + ACHIEVEMENTS criteria
897
+ const near = [];
898
+ const progressCounters = achievements.progress || {};
899
+ const unlockedIds = new Set(unlocked.map(a => a.id));
900
+ for (const def of ACHIEVEMENTS) {
901
+ if (unlockedIds.has(def.id)) continue;
902
+ const counter = progressCounters[def.id];
903
+ if (counter && counter.current > 0) {
904
+ near.push({
905
+ id: def.id,
906
+ icon: def.icon,
907
+ name: def.name,
908
+ description: def.description,
909
+ category: def.category,
910
+ current: counter.current,
911
+ target: counter.target || def.criteria?.target || 1,
912
+ });
913
+ }
914
+ }
915
+ near.sort((a, b) => (b.current / b.target) - (a.current / a.target));
916
+
917
+ sendJson(res, 200, {
918
+ ...progress,
919
+ achievements: { unlocked, near },
920
+ });
493
921
  }
494
922
 
495
923
  /**
496
- * GET /api/eventspoll queued user events.
924
+ * GET /api/modulesreturn module catalog from built-in + installed modules.
497
925
  */
498
- function handleApiPollEvents(res, router) {
499
- const events = router.pollEvents();
500
- sendJson(res, 200, { events, count: events.length });
926
+ function handleApiModules(res) {
927
+ const modulesDir = resolve(__dirname, '..', '..', 'modules');
928
+ const modules = [];
929
+
930
+ try {
931
+ if (existsSync(modulesDir)) {
932
+ const entries = readdirSync(modulesDir);
933
+ for (const entry of entries) {
934
+ const yamlPath = join(modulesDir, entry, 'module.yaml');
935
+ if (!existsSync(yamlPath)) continue;
936
+ try {
937
+ const raw = readFileSync(yamlPath, 'utf-8');
938
+ // Simple YAML parsing for module metadata (no dependency needed)
939
+ const meta = {};
940
+ for (const line of raw.split('\n')) {
941
+ const match = line.match(/^(\w[\w-]*):\s*(.+)$/);
942
+ if (match) {
943
+ let val = match[2].trim();
944
+ if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
945
+ if (val === 'true') val = true;
946
+ else if (val === 'false') val = false;
947
+ else if (/^\d+$/.test(val)) val = parseInt(val, 10);
948
+ meta[match[1]] = val;
949
+ }
950
+ }
951
+ if (meta.slug) {
952
+ const hasWorkshop = existsSync(join(modulesDir, entry, 'workshop.yaml'));
953
+ modules.push({
954
+ slug: meta.slug,
955
+ title: meta.title || meta.slug,
956
+ description: meta.description || '',
957
+ category: meta.category || 'general',
958
+ difficulty: meta.difficulty || 'beginner',
959
+ icon: meta.icon || null,
960
+ hasWorkshop,
961
+ });
962
+ }
963
+ } catch {
964
+ // Skip unparseable modules
965
+ }
966
+ }
967
+ }
968
+ } catch {
969
+ // Directory read failed
970
+ }
971
+
972
+ sendJson(res, 200, { modules });
501
973
  }
502
974
 
503
975
  /**
504
- * GET /api/events/wait?timeout=30long-poll for queued user events.
505
- * Holds the connection open until events arrive or timeout expires.
506
- * Timeout is in seconds; defaults to 30, capped at MAX_LONG_POLL_TIMEOUT_MS.
976
+ * GET /api/journeysreturn all journeys with resolved module status and progress.
507
977
  */
508
- function handleApiPollEventsWait(req, res, router, url) {
509
- const timeoutParam = parseInt(url.searchParams.get('timeout') || '30', 10);
510
- const timeoutSec = isNaN(timeoutParam) ? 30 : timeoutParam;
511
- const timeoutMs = Math.min(
512
- Math.max(timeoutSec * 1000, 1000),
513
- MAX_LONG_POLL_TIMEOUT_MS
514
- );
978
+ function handleApiJourneys(res, options) {
979
+ try {
980
+ const journeysDir = resolve(__dirname, '..', '..', 'journeys');
981
+ const journeys = [];
982
+
983
+ if (existsSync(journeysDir)) {
984
+ const files = readdirSync(journeysDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
985
+ for (const file of files) {
986
+ try {
987
+ const raw = readFileSync(join(journeysDir, file), 'utf-8');
988
+ const parsed = parseYamlSimple(raw);
989
+ if (parsed && parsed.slug) {
990
+ journeys.push(parsed);
991
+ }
992
+ } catch { /* skip */ }
993
+ }
994
+ }
515
995
 
516
- let settled = false;
996
+ let progress = { modules: {}, journey: { active: null, progress: {} } };
997
+ if (options.progressProvider && typeof options.progressProvider.getProgress === 'function') {
998
+ progress = options.progressProvider.getProgress();
999
+ }
517
1000
 
518
- // Handle client disconnect before timeout.
519
- // IMPORTANT: Use res.on('close'), NOT req.on('close').
520
- // req 'close' fires when the request body is consumed (immediately after parsing),
521
- // but res 'close' fires when the underlying connection is actually closed.
522
- res.on('close', () => {
523
- settled = true;
524
- });
1001
+ const modulesDir = resolve(__dirname, '..', '..', 'modules');
1002
+ const moduleMap = buildModuleMap(modulesDir);
525
1003
 
526
- router.pollEventsAsync(timeoutMs).then((events) => {
527
- if (settled) return; // Client already disconnected
528
- settled = true;
529
- sendJson(res, 200, { events, count: events.length });
1004
+ const resolved = journeys.map(j => resolveJourneyForApi(j, moduleMap, progress));
1005
+ sendJson(res, 200, {
1006
+ journeys: resolved,
1007
+ activeJourney: progress.journey?.active || null,
1008
+ });
1009
+ } catch {
1010
+ sendJson(res, 200, { journeys: [], activeJourney: null });
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * POST /api/journeys/activate — set or clear the active journey.
1016
+ */
1017
+ function handleApiJourneyActivate(req, res, options) {
1018
+ readBody(req, (err, body) => {
1019
+ if (err) return sendJson(res, 400, { error: 'Invalid request body' });
1020
+ try {
1021
+ const { slug } = body;
1022
+ if (options.progressProvider && typeof options.progressProvider.getProgress === 'function') {
1023
+ const progress = options.progressProvider.getProgress();
1024
+ if (!progress.journey) progress.journey = { active: null, progress: {} };
1025
+
1026
+ if (slug) {
1027
+ progress.journey.active = slug;
1028
+ if (!progress.journey.progress[slug]) {
1029
+ progress.journey.progress[slug] = { started: new Date().toISOString().split('T')[0] };
1030
+ }
1031
+ } else {
1032
+ progress.journey.active = null;
1033
+ }
1034
+
1035
+ if (typeof options.progressProvider.saveProgress === 'function') {
1036
+ options.progressProvider.saveProgress(progress);
1037
+ }
1038
+ sendJson(res, 200, { ok: true, active: progress.journey.active });
1039
+ } else {
1040
+ sendJson(res, 200, { ok: true, active: null });
1041
+ }
1042
+ } catch (activateErr) {
1043
+ sendJson(res, 500, { error: activateErr.message });
1044
+ }
530
1045
  });
531
1046
  }
532
1047
 
533
1048
  /**
534
- * GET /api/templates list available templates.
1049
+ * Build a map of module slugs to basic metadata from the modules directory.
535
1050
  */
536
- function handleApiTemplates(res) {
537
- sendJson(res, 200, { templates: listTemplates() });
1051
+ function buildModuleMap(modulesDir) {
1052
+ const map = new Map();
1053
+ try {
1054
+ if (!existsSync(modulesDir)) return map;
1055
+ for (const entry of readdirSync(modulesDir)) {
1056
+ const yamlPath = join(modulesDir, entry, 'module.yaml');
1057
+ if (!existsSync(yamlPath)) continue;
1058
+ try {
1059
+ const raw = readFileSync(yamlPath, 'utf-8');
1060
+ const meta = parseYamlSimple(raw);
1061
+ if (meta?.slug) {
1062
+ map.set(meta.slug, {
1063
+ slug: meta.slug,
1064
+ title: meta.title || meta.slug,
1065
+ description: meta.description || '',
1066
+ difficulty: meta.difficulty || 'beginner',
1067
+ });
1068
+ }
1069
+ } catch { /* skip */ }
1070
+ }
1071
+ } catch { /* skip */ }
1072
+ return map;
1073
+ }
1074
+
1075
+ /**
1076
+ * Parse simple flat YAML fields (no nested structures).
1077
+ * For journey files we need nested parsing, so use JSON-safe subset.
1078
+ */
1079
+ function parseYamlSimple(raw) {
1080
+ try {
1081
+ // Use dynamic import workaround — js-yaml may not be available in bridge
1082
+ // Instead, parse the YAML structure manually for journeys
1083
+ const lines = raw.split('\n');
1084
+ const result = {};
1085
+ let currentKey = null;
1086
+ let currentArray = null;
1087
+ let inStages = false;
1088
+ let currentStage = null;
1089
+ let inModules = false;
1090
+
1091
+ for (const line of lines) {
1092
+ const trimmed = line.trimEnd();
1093
+
1094
+ // Top-level scalar
1095
+ const scalarMatch = trimmed.match(/^(\w[\w_-]*):\s*(.+)$/);
1096
+ if (scalarMatch && !inStages) {
1097
+ currentKey = scalarMatch[1];
1098
+ let val = scalarMatch[2].trim();
1099
+ if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
1100
+ if (val.startsWith('[') && val.endsWith(']')) {
1101
+ val = val.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, ''));
1102
+ } else if (/^\d+$/.test(val)) {
1103
+ val = parseInt(val, 10);
1104
+ }
1105
+ result[currentKey] = val;
1106
+ continue;
1107
+ }
1108
+
1109
+ // Top-level key with no value (start of block)
1110
+ const blockMatch = trimmed.match(/^(\w[\w_-]*):\s*$/);
1111
+ if (blockMatch) {
1112
+ currentKey = blockMatch[1];
1113
+ if (currentKey === 'stages') {
1114
+ inStages = true;
1115
+ result.stages = [];
1116
+ }
1117
+ continue;
1118
+ }
1119
+
1120
+ if (inStages) {
1121
+ // New stage entry
1122
+ const stageStart = trimmed.match(/^\s+-\s+name:\s*"?(.+?)"?\s*$/);
1123
+ if (stageStart) {
1124
+ currentStage = { name: stageStart[1], description: '', modules: [] };
1125
+ result.stages.push(currentStage);
1126
+ inModules = false;
1127
+ continue;
1128
+ }
1129
+
1130
+ // Stage description
1131
+ const descMatch = trimmed.match(/^\s+description:\s*"?(.+?)"?\s*$/);
1132
+ if (descMatch && currentStage) {
1133
+ currentStage.description = descMatch[1];
1134
+ continue;
1135
+ }
1136
+
1137
+ // Modules block start
1138
+ if (trimmed.match(/^\s+modules:\s*$/)) {
1139
+ inModules = true;
1140
+ continue;
1141
+ }
1142
+
1143
+ // Module entry — object form { slug: x, title: y }
1144
+ const objModule = trimmed.match(/^\s+-\s*\{\s*slug:\s*(\S+),\s*title:\s*"(.+?)"\s*\}/);
1145
+ if (objModule && inModules && currentStage) {
1146
+ currentStage.modules.push({ slug: objModule[1], title: objModule[2] });
1147
+ continue;
1148
+ }
1149
+
1150
+ // Module entry — plain slug
1151
+ const slugModule = trimmed.match(/^\s+-\s+(\S+)\s*$/);
1152
+ if (slugModule && inModules && currentStage) {
1153
+ currentStage.modules.push(slugModule[1]);
1154
+ continue;
1155
+ }
1156
+ }
1157
+ }
1158
+
1159
+ return result;
1160
+ } catch {
1161
+ return null;
1162
+ }
538
1163
  }
539
1164
 
540
1165
  /**
541
- * POST /api/visual push a visual command to connected clients.
542
- * Used by the plugin to send canvas:* commands.
543
- * REJECTS canvas:quiz messages — those MUST go through POST /api/quiz.
1166
+ * Resolve a journey's modules against the module map and progress.
544
1167
  */
545
- function handleApiVisual(req, res, router) {
1168
+ function resolveJourneyForApi(journey, moduleMap, progress) {
1169
+ const moduleProgress = progress.modules || {};
1170
+ let totalModules = 0;
1171
+ let completedModules = 0;
1172
+ let comingSoonModules = 0;
1173
+
1174
+ const stages = (journey.stages || []).map((stage, idx) => {
1175
+ const modules = (stage.modules || []).map(ref => {
1176
+ totalModules++;
1177
+ const slug = typeof ref === 'string' ? ref : ref.slug;
1178
+ const titleHint = typeof ref === 'object' ? ref.title : null;
1179
+ const regMod = moduleMap.get(slug);
1180
+ const modProg = moduleProgress[slug];
1181
+
1182
+ if (!regMod) {
1183
+ comingSoonModules++;
1184
+ return { slug, title: titleHint || slug, status: 'coming_soon' };
1185
+ }
1186
+
1187
+ let status = 'available';
1188
+ if (modProg?.status === 'completed') { status = 'completed'; completedModules++; }
1189
+ else if (modProg?.status === 'in-progress') { status = 'in_progress'; }
1190
+
1191
+ return { slug, title: regMod.title, status, difficulty: regMod.difficulty };
1192
+ });
1193
+
1194
+ const existing = modules.filter(m => m.status !== 'coming_soon');
1195
+ const done = existing.length > 0 && existing.every(m => m.status === 'completed');
1196
+ const inProg = modules.some(m => m.status === 'in_progress' || m.status === 'completed') && !done;
1197
+
1198
+ let stageStatus = 'locked';
1199
+ if (done) stageStatus = 'completed';
1200
+ else if (inProg) stageStatus = 'in_progress';
1201
+
1202
+ return { name: stage.name, description: stage.description || '', stageIndex: idx, status: stageStatus, modules };
1203
+ });
1204
+
1205
+ // Unlock logic
1206
+ for (let i = 0; i < stages.length; i++) {
1207
+ if (i === 0 && stages[i].status === 'locked') {
1208
+ stages[i].status = 'available';
1209
+ stages[i].modules.forEach(m => { if (m.status !== 'coming_soon' && m.status !== 'completed' && m.status !== 'in_progress') m.status = 'available'; });
1210
+ } else if (i > 0 && stages[i - 1].status === 'completed' && stages[i].status === 'locked') {
1211
+ stages[i].status = 'available';
1212
+ stages[i].modules.forEach(m => { if (m.status !== 'coming_soon' && m.status !== 'completed' && m.status !== 'in_progress') m.status = 'available'; });
1213
+ }
1214
+ }
1215
+
1216
+ const currentStageIndex = stages.findIndex(s => s.status !== 'completed');
1217
+ const availableModules = totalModules - comingSoonModules;
1218
+
1219
+ return {
1220
+ slug: journey.slug,
1221
+ title: journey.title,
1222
+ description: journey.description || '',
1223
+ icon: journey.icon || 'book',
1224
+ tags: Array.isArray(journey.tags) ? journey.tags : [],
1225
+ difficulty: journey.difficulty || 'beginner-to-advanced',
1226
+ estimatedHours: journey.estimated_hours || null,
1227
+ stages,
1228
+ stats: {
1229
+ totalModules,
1230
+ completedModules,
1231
+ comingSoonModules,
1232
+ availableModules,
1233
+ totalStages: stages.length,
1234
+ completedStages: stages.filter(s => s.status === 'completed').length,
1235
+ currentStageIndex: currentStageIndex >= 0 ? currentStageIndex : stages.length - 1,
1236
+ progressPercent: availableModules > 0 ? Math.round((completedModules / availableModules) * 100) : 0,
1237
+ },
1238
+ };
1239
+ }
1240
+
1241
+ /**
1242
+ * POST /api/progress — write progress updates.
1243
+ * Accepts { xp, module, status, walkthroughStep } and merges into the progress file.
1244
+ * Uses progressProvider.saveProgress() when available for proper YAML serialization.
1245
+ * Broadcasts updated progress to all connected canvas clients.
1246
+ */
1247
+ function handleApiProgressWrite(req, res, options, router) {
546
1248
  readBody(req, (err, body) => {
547
1249
  if (err) {
548
1250
  sendJson(res, 400, { error: 'Invalid request body' });
549
1251
  return;
550
1252
  }
551
1253
 
552
- const { valid, envelope, error } = parseEnvelope(body);
553
- if (!valid) {
554
- sendJson(res, 400, { error });
1254
+ if (!body || typeof body !== 'object') {
1255
+ sendJson(res, 400, { error: 'Body must be a JSON object' });
555
1256
  return;
556
1257
  }
557
1258
 
558
- // Reject quiz messages — they must use POST /api/quiz to capture answers
559
- if (envelope.type === 'canvas:quiz') {
560
- sendJson(res, 400, {
561
- error: 'Quiz messages must use POST /api/quiz, not /api/visual. The /api/quiz endpoint sends the quiz AND waits for the browser answer in one request.',
562
- });
563
- return;
1259
+ const provider = options.progressProvider;
1260
+ const hasProvider = provider
1261
+ && typeof provider.getProgress === 'function'
1262
+ && typeof provider.saveProgress === 'function';
1263
+
1264
+ try {
1265
+ const progress = hasProvider
1266
+ ? provider.getProgress()
1267
+ : { user: { xp: 0 }, modules: {} };
1268
+
1269
+ if (!progress.user) progress.user = { xp: 0 };
1270
+ if (!progress.modules) progress.modules = {};
1271
+
1272
+ if (typeof body.xp === 'number') {
1273
+ progress.user.xp = body.xp;
1274
+ }
1275
+
1276
+ if (body.module && body.status) {
1277
+ if (!progress.modules[body.module]) {
1278
+ progress.modules[body.module] = {
1279
+ status: body.status,
1280
+ started: new Date().toISOString().split('T')[0],
1281
+ };
1282
+ } else {
1283
+ progress.modules[body.module].status = body.status;
1284
+ }
1285
+ progress.modules[body.module].last_session = new Date().toISOString().split('T')[0];
1286
+
1287
+ if (typeof body.walkthroughStep === 'number') {
1288
+ progress.modules[body.module].walkthrough_step = body.walkthroughStep;
1289
+ }
1290
+ }
1291
+
1292
+ progress.user.modules_completed = Object.values(progress.modules)
1293
+ .filter(m => m.status === 'completed').length;
1294
+
1295
+ if (hasProvider) {
1296
+ provider.saveProgress(progress);
1297
+ }
1298
+
1299
+ // Broadcast updated progress to connected canvas clients (enriched with catalog metadata)
1300
+ if (router) {
1301
+ const modulesDir = resolve(__dirname, '..', '..', 'modules');
1302
+ const moduleMap = buildModuleMap(modulesDir);
1303
+ const enriched = enrichModulesWithMetadata(progress.modules, moduleMap);
1304
+ const modules = Object.entries(enriched).map(([slug, m]) => ({ slug, ...m }));
1305
+ const dashboardEnvelope = createEnvelope('canvas:dashboard', {
1306
+ progress: {
1307
+ xp: progress.user.xp || 0,
1308
+ belt: progress.user.belt,
1309
+ modules,
1310
+ completedCount: modules.filter(m => m.status === 'completed').length,
1311
+ totalCount: modules.length,
1312
+ },
1313
+ }, 'bridge');
1314
+ router.routeVisualCommand(dashboardEnvelope);
1315
+ }
1316
+
1317
+ sendJson(res, 200, { ok: true });
1318
+ } catch (writeErr) {
1319
+ sendJson(res, 500, { error: writeErr.message });
564
1320
  }
1321
+ });
1322
+ }
565
1323
 
566
- // Reject game messages — they must use POST /api/game to capture results
567
- if (envelope.type === 'canvas:game') {
568
- sendJson(res, 400, {
569
- error: 'Game messages must use POST /api/game, not /api/visual. The /api/game endpoint sends the game AND waits for the browser result in one request.',
570
- });
571
- return;
1324
+ /**
1325
+ * GET /api/module/:slug — serve full module content.
1326
+ */
1327
+ function handleApiModuleContent(res, slug) {
1328
+ const modulesDir = resolve(__dirname, '..', '..', 'modules');
1329
+ const moduleDir = join(modulesDir, slug);
1330
+
1331
+ if (!existsSync(join(moduleDir, 'module.yaml'))) {
1332
+ sendJson(res, 404, { error: `Module "${slug}" not found` });
1333
+ return;
1334
+ }
1335
+
1336
+ try {
1337
+ const files = {};
1338
+ const entries = readdirSync(moduleDir);
1339
+ for (const entry of entries) {
1340
+ const filePath = join(moduleDir, entry);
1341
+ try {
1342
+ const stat = statSync(filePath);
1343
+ if (stat.isFile() && stat.size < 100_000) {
1344
+ files[entry] = readFileSync(filePath, 'utf-8');
1345
+ }
1346
+ } catch { /* skip unreadable files */ }
572
1347
  }
573
1348
 
574
- // Check if we should render a template
575
- if (envelope.payload && envelope.payload.template) {
576
- const rendered = renderTemplate(envelope.payload.template, envelope.payload.data || {});
577
- if (rendered) {
578
- envelope.payload.renderedHtml = rendered;
1349
+ const meta = {};
1350
+ const yamlRaw = files['module.yaml'] || '';
1351
+ for (const line of yamlRaw.split('\n')) {
1352
+ const match = line.match(/^(\w[\w-]*):\s*(.+)$/);
1353
+ if (match) {
1354
+ let val = match[2].trim();
1355
+ if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
1356
+ if (val === 'true') val = true;
1357
+ else if (val === 'false') val = false;
1358
+ else if (/^\d+$/.test(val)) val = parseInt(val, 10);
1359
+ meta[match[1]] = val;
579
1360
  }
580
1361
  }
581
1362
 
582
- router.routeVisualCommand(envelope);
583
- sendJson(res, 200, { ok: true, tier: router.tierManager.getTier() });
584
- });
1363
+ // Include list of static resources if resources/ directory exists
1364
+ const resourcesDir = join(moduleDir, 'resources');
1365
+ let resources = [];
1366
+ try {
1367
+ if (existsSync(resourcesDir)) {
1368
+ resources = readdirSync(resourcesDir).filter(f => {
1369
+ try { return statSync(join(resourcesDir, f)).isFile(); } catch { return false; }
1370
+ });
1371
+ }
1372
+ } catch { /* no resources */ }
1373
+
1374
+ sendJson(res, 200, { slug, meta, files, resources });
1375
+ } catch (readErr) {
1376
+ sendJson(res, 500, { error: readErr.message });
1377
+ }
585
1378
  }
586
1379
 
1380
+ const RESOURCE_CONTENT_TYPES = {
1381
+ '.png': 'image/png',
1382
+ '.jpg': 'image/jpeg',
1383
+ '.jpeg': 'image/jpeg',
1384
+ '.gif': 'image/gif',
1385
+ '.svg': 'image/svg+xml',
1386
+ '.webp': 'image/webp',
1387
+ '.avif': 'image/avif',
1388
+ '.mp4': 'video/mp4',
1389
+ '.webm': 'video/webm',
1390
+ '.ogg': 'video/ogg',
1391
+ '.html': 'text/html',
1392
+ '.css': 'text/css',
1393
+ '.js': 'text/javascript',
1394
+ '.json': 'application/json',
1395
+ };
1396
+ const MAX_RESOURCE_SIZE = 10 * 1024 * 1024; // 10 MB
1397
+
587
1398
  /**
588
- * POST /api/quiz?timeout=30send a visual quiz AND wait for the browser answer.
589
- * Combines /api/visual + /api/events/wait into one atomic request so the caller
590
- * cannot accidentally skip the poll step.
1399
+ * GET /api/module/:slug/resources/:filenameserve static resource files.
591
1400
  */
592
- function handleApiQuiz(req, res, router, url) {
593
- const timeoutParam = parseInt(url.searchParams.get('timeout') || '30', 10);
594
- const timeoutSec = isNaN(timeoutParam) ? 30 : timeoutParam;
595
- const timeoutMs = Math.min(
596
- Math.max(timeoutSec * 1000, 1000),
597
- MAX_LONG_POLL_TIMEOUT_MS
598
- );
1401
+ function handleApiModuleResource(res, slug, filename) {
1402
+ if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
1403
+ sendJson(res, 400, { error: 'Invalid filename' });
1404
+ return;
1405
+ }
1406
+
1407
+ const modulesDir = resolve(__dirname, '..', '..', 'modules');
1408
+ const filePath = join(modulesDir, slug, 'resources', filename);
1409
+
1410
+ if (!existsSync(filePath)) {
1411
+ sendJson(res, 404, { error: 'Resource not found' });
1412
+ return;
1413
+ }
1414
+
1415
+ try {
1416
+ const stat = statSync(filePath);
1417
+ if (!stat.isFile() || stat.size > MAX_RESOURCE_SIZE) {
1418
+ sendJson(res, 400, { error: 'Resource too large or not a file' });
1419
+ return;
1420
+ }
1421
+
1422
+ const ext = '.' + filename.split('.').pop().toLowerCase();
1423
+ const contentType = RESOURCE_CONTENT_TYPES[ext] || 'application/octet-stream';
1424
+
1425
+ res.writeHead(200, {
1426
+ 'Content-Type': contentType,
1427
+ 'Content-Length': stat.size,
1428
+ 'Cache-Control': 'public, max-age=86400',
1429
+ });
1430
+ const stream = createReadStream(filePath);
1431
+ stream.pipe(res);
1432
+ stream.on('error', () => {
1433
+ if (!res.headersSent) sendJson(res, 500, { error: 'Read error' });
1434
+ });
1435
+ } catch (err) {
1436
+ sendJson(res, 500, { error: err.message });
1437
+ }
1438
+ }
1439
+
1440
+ // --- Session management handlers ---
1441
+
1442
+ /**
1443
+ * GET /api/sessions — list all sessions (metadata only).
1444
+ */
1445
+ function handleApiSessionsList(res) {
1446
+ try {
1447
+ const sessions = listStoredSessions();
1448
+ sendJson(res, 200, { sessions });
1449
+ } catch (err) {
1450
+ sendJson(res, 500, { error: err.message });
1451
+ }
1452
+ }
1453
+
1454
+ /**
1455
+ * GET /api/sessions/:id — get full session with messages.
1456
+ */
1457
+ function handleApiSessionGet(res, sessionId) {
1458
+ try {
1459
+ const session = getStoredSession(sessionId);
1460
+ if (!session) {
1461
+ sendJson(res, 404, { error: 'Session not found' });
1462
+ return;
1463
+ }
1464
+ sendJson(res, 200, session);
1465
+ } catch (err) {
1466
+ sendJson(res, 500, { error: err.message });
1467
+ }
1468
+ }
599
1469
 
1470
+ /**
1471
+ * PUT /api/sessions/:id — update session metadata.
1472
+ */
1473
+ function handleApiSessionUpdate(req, res, sessionId) {
600
1474
  readBody(req, (err, body) => {
601
1475
  if (err) {
602
1476
  sendJson(res, 400, { error: 'Invalid request body' });
603
1477
  return;
604
1478
  }
1479
+ try {
1480
+ const updated = updateStoredMeta(sessionId, body);
1481
+ if (!updated) {
1482
+ sendJson(res, 404, { error: 'Session not found' });
1483
+ return;
1484
+ }
1485
+ sendJson(res, 200, { ok: true, session: {
1486
+ sessionId: updated.sessionId,
1487
+ title: updated.title,
1488
+ module: updated.module,
1489
+ topic: updated.topic,
1490
+ pinned: updated.pinned,
1491
+ }});
1492
+ } catch (err) {
1493
+ sendJson(res, 500, { error: err.message });
1494
+ }
1495
+ });
1496
+ }
605
1497
 
606
- const { valid, envelope, error } = parseEnvelope(body);
607
- if (!valid) {
608
- sendJson(res, 400, { error });
609
- return;
1498
+ /**
1499
+ * DELETE /api/sessions/:id — delete a single session.
1500
+ */
1501
+ function handleApiSessionDelete(res, sessionId, agentServer) {
1502
+ try {
1503
+ // Close SDK session if active
1504
+ if (agentServer?.sessions) {
1505
+ agentServer.sessions.closeSession(sessionId).catch(() => {});
610
1506
  }
1507
+ const deleted = deleteStoredSession(sessionId);
1508
+ sendJson(res, 200, { ok: true, deleted });
1509
+ } catch (err) {
1510
+ sendJson(res, 500, { error: err.message });
1511
+ }
1512
+ }
611
1513
 
612
- // Render template if needed (same as /api/visual)
613
- if (envelope.payload && envelope.payload.template) {
614
- const rendered = renderTemplate(envelope.payload.template, envelope.payload.data || {});
615
- if (rendered) {
616
- envelope.payload.renderedHtml = rendered;
1514
+ /**
1515
+ * DELETE /api/sessions — purge all session history.
1516
+ */
1517
+ function handleApiSessionsPurge(res, agentServer) {
1518
+ try {
1519
+ if (agentServer?.sessions) {
1520
+ agentServer.sessions.closeAll().catch(() => {});
1521
+ }
1522
+ const count = purgeStoredSessions();
1523
+ sendJson(res, 200, { ok: true, purged: count });
1524
+ } catch (err) {
1525
+ sendJson(res, 500, { error: err.message });
1526
+ }
1527
+ }
1528
+
1529
+ /**
1530
+ * Settings file path: ~/.socrates/config.yaml
1531
+ */
1532
+ const SETTINGS_DIR = join(
1533
+ process.env.HOME || process.env.USERPROFILE || '/tmp',
1534
+ '.socrates'
1535
+ );
1536
+ const SETTINGS_FILE = join(SETTINGS_DIR, 'config.yaml');
1537
+
1538
+ const DEFAULT_SETTINGS = {
1539
+ theme: 'dark',
1540
+ accent: 'blue',
1541
+ chatPosition: 'left',
1542
+ fontSize: 14,
1543
+ model: 'claude-sonnet-4-6',
1544
+ animations: { confetti: true, transitions: true, diagrams: true },
1545
+ };
1546
+
1547
+ function readSettings() {
1548
+ try {
1549
+ if (!existsSync(SETTINGS_FILE)) return { ...DEFAULT_SETTINGS };
1550
+ const raw = readFileSync(SETTINGS_FILE, 'utf-8');
1551
+ const settings = { ...DEFAULT_SETTINGS };
1552
+ for (const line of raw.split('\n')) {
1553
+ const match = line.match(/^(\w[\w-]*):\s*(.+)$/);
1554
+ if (match) {
1555
+ let val = match[2].trim();
1556
+ if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
1557
+ if (val === 'true') val = true;
1558
+ else if (val === 'false') val = false;
1559
+ else if (/^\d+$/.test(val)) val = parseInt(val, 10);
1560
+ settings[match[1]] = val;
617
1561
  }
618
1562
  }
1563
+ return settings;
1564
+ } catch {
1565
+ return { ...DEFAULT_SETTINGS };
1566
+ }
1567
+ }
619
1568
 
620
- // Step 1: Send the quiz to connected clients
621
- router.routeVisualCommand(envelope);
1569
+ function writeSettings(settings) {
1570
+ try {
1571
+ if (!existsSync(SETTINGS_DIR)) mkdirSync(SETTINGS_DIR, { recursive: true });
1572
+ const lines = [];
1573
+ for (const [key, val] of Object.entries(settings)) {
1574
+ if (typeof val === 'object') {
1575
+ // Flatten nested objects (animations)
1576
+ for (const [k, v] of Object.entries(val)) {
1577
+ lines.push(`${key}_${k}: ${v}`);
1578
+ }
1579
+ } else {
1580
+ lines.push(`${key}: ${val}`);
1581
+ }
1582
+ }
1583
+ writeFileSync(SETTINGS_FILE, lines.join('\n') + '\n');
1584
+ } catch {
1585
+ // Ignore write errors
1586
+ }
1587
+ }
622
1588
 
623
- // Step 2: Wait for the browser answer
624
- // Use res.on('close')req 'close' fires when body is consumed (too early!)
625
- let settled = false;
626
- res.on('close', () => { settled = true; });
1589
+ /**
1590
+ * GET /api/settings return canvas settings.
1591
+ */
1592
+ function handleApiSettingsGet(res) {
1593
+ const settings = readSettings();
1594
+ sendJson(res, 200, settings);
1595
+ }
627
1596
 
628
- router.pollEventsAsync(timeoutMs).then((events) => {
629
- if (settled) return;
630
- settled = true;
631
- sendJson(res, 200, {
632
- ok: true,
633
- tier: router.tierManager.getTier(),
634
- events,
635
- count: events.length,
636
- });
637
- });
1597
+ /**
1598
+ * POST /api/settings — save canvas settings (partial update).
1599
+ */
1600
+ function handleApiSettingsPost(req, res) {
1601
+ let body = '';
1602
+ req.on('data', (chunk) => { body += chunk; });
1603
+ req.on('end', () => {
1604
+ try {
1605
+ const patch = JSON.parse(body);
1606
+ const current = readSettings();
1607
+ // Merge patch into current settings
1608
+ const updated = { ...current };
1609
+ for (const [key, val] of Object.entries(patch)) {
1610
+ if (key === 'animations' && typeof val === 'object') {
1611
+ updated.animations = { ...current.animations, ...val };
1612
+ } else if (DEFAULT_SETTINGS[key] !== undefined) {
1613
+ updated[key] = val;
1614
+ }
1615
+ }
1616
+ writeSettings(updated);
1617
+ sendJson(res, 200, updated);
1618
+ } catch {
1619
+ sendJson(res, 400, { error: 'Invalid JSON' });
1620
+ }
638
1621
  });
639
1622
  }
640
1623
 
641
1624
  /**
642
- * POST /api/game?timeout=120send a game to canvas AND wait for the game result.
643
- * Mirrors /api/quiz: combines /api/visual + /api/events/wait into one atomic request
644
- * so hooks cannot steal the event:game-result between the two calls.
1625
+ * GET /api/eventspoll queued user events.
645
1626
  */
646
- function handleApiGame(req, res, router, url) {
647
- const timeoutParam = parseInt(url.searchParams.get('timeout') || '120', 10);
648
- const timeoutSec = isNaN(timeoutParam) ? 120 : timeoutParam;
1627
+ function handleApiPollEvents(res, router) {
1628
+ const events = router.pollEvents();
1629
+ sendJson(res, 200, { events, count: events.length });
1630
+ }
1631
+
1632
+ /**
1633
+ * GET /api/events/wait?timeout=30 — long-poll for queued user events.
1634
+ * Holds the connection open until events arrive or timeout expires.
1635
+ * Timeout is in seconds; defaults to 30, capped at MAX_LONG_POLL_TIMEOUT_MS.
1636
+ */
1637
+ function handleApiPollEventsWait(req, res, router, url) {
1638
+ const timeoutParam = parseInt(url.searchParams.get('timeout') || '30', 10);
1639
+ const timeoutSec = isNaN(timeoutParam) ? 30 : timeoutParam;
649
1640
  const timeoutMs = Math.min(
650
1641
  Math.max(timeoutSec * 1000, 1000),
651
1642
  MAX_LONG_POLL_TIMEOUT_MS
652
1643
  );
653
1644
 
1645
+ let settled = false;
1646
+
1647
+ // Handle client disconnect before timeout.
1648
+ // IMPORTANT: Use res.on('close'), NOT req.on('close').
1649
+ // req 'close' fires when the request body is consumed (immediately after parsing),
1650
+ // but res 'close' fires when the underlying connection is actually closed.
1651
+ res.on('close', () => {
1652
+ settled = true;
1653
+ });
1654
+
1655
+ router.pollEventsAsync(timeoutMs).then((events) => {
1656
+ if (settled) return; // Client already disconnected
1657
+ settled = true;
1658
+ sendJson(res, 200, { events, count: events.length });
1659
+ });
1660
+ }
1661
+
1662
+ /**
1663
+ * GET /api/templates — list available templates.
1664
+ */
1665
+ function handleApiTemplates(res) {
1666
+ sendJson(res, 200, { templates: listTemplates() });
1667
+ }
1668
+
1669
+ /**
1670
+ * POST /api/canvas — unified endpoint for ALL visual commands.
1671
+ *
1672
+ * Accepts any canvas:* envelope. If the envelope contains an `await` field,
1673
+ * the response is held until a matching event arrives or timeout expires.
1674
+ *
1675
+ * Without `await`: fire-and-forget (diagrams, dashboard, celebrations, HTML).
1676
+ * With `await`: send visual + long-poll for matching event (quizzes, games, etc).
1677
+ *
1678
+ * Envelope format:
1679
+ * { v, type, payload, source, timestamp, await?: { event, timeout } }
1680
+ */
1681
+ function handleApiCanvas(req, res, router, url) {
654
1682
  readBody(req, (err, body) => {
655
1683
  if (err) {
656
1684
  sendJson(res, 400, { error: 'Invalid request body' });
@@ -663,7 +1691,6 @@ function handleApiGame(req, res, router, url) {
663
1691
  return;
664
1692
  }
665
1693
 
666
- // Render template if needed (same as /api/visual)
667
1694
  if (envelope.payload && envelope.payload.template) {
668
1695
  const rendered = renderTemplate(envelope.payload.template, envelope.payload.data || {});
669
1696
  if (rendered) {
@@ -671,13 +1698,20 @@ function handleApiGame(req, res, router, url) {
671
1698
  }
672
1699
  }
673
1700
 
674
- // Step 1: Send the game to connected clients
675
1701
  router.routeVisualCommand(envelope);
676
1702
 
677
- // Step 2: Wait for the game result
678
- // IMPORTANT: Use res.on('close'), NOT req.on('close').
679
- // req 'close' fires when the request body is consumed (immediately after parsing),
680
- // but res 'close' fires when the underlying connection is actually closed.
1703
+ const awaitSpec = envelope.await;
1704
+ if (!awaitSpec || !awaitSpec.event) {
1705
+ sendJson(res, 200, { ok: true, tier: router.tierManager.getTier() });
1706
+ return;
1707
+ }
1708
+
1709
+ const timeoutSec = typeof awaitSpec.timeout === 'number' ? awaitSpec.timeout : 30;
1710
+ const timeoutMs = Math.min(
1711
+ Math.max(timeoutSec * 1000, 1000),
1712
+ MAX_LONG_POLL_TIMEOUT_MS
1713
+ );
1714
+
681
1715
  let settled = false;
682
1716
  res.on('close', () => { settled = true; });
683
1717
 
@@ -767,18 +1801,7 @@ function handleApiOpenExtension(res) {
767
1801
  return;
768
1802
  }
769
1803
 
770
- const platform = process.platform;
771
- if (platform === 'darwin') {
772
- exec(`open "${extensionDir}"`);
773
- exec('open -a "Google Chrome" "chrome://extensions"');
774
- } else if (platform === 'linux') {
775
- exec(`xdg-open "${extensionDir}"`);
776
- exec('google-chrome "chrome://extensions" 2>/dev/null || chromium-browser "chrome://extensions" 2>/dev/null');
777
- } else if (platform === 'win32') {
778
- exec(`explorer "${extensionDir}"`);
779
- exec('start chrome "chrome://extensions"');
780
- }
781
-
1804
+ // Return the path only — no longer auto-opens Finder/Chrome.
782
1805
  sendJson(res, 200, { ok: true, path: extensionDir });
783
1806
  }
784
1807
 
@@ -829,101 +1852,126 @@ function handleApiTerminalExec(req, res, router) {
829
1852
  });
830
1853
  }
831
1854
 
832
- // --- Chat API handlers ---
833
-
834
- function handleApiChatStart(req, res, chatSession, sessionClientMap, createTargetedSend, options) {
1855
+ /**
1856
+ * POST /api/pty/create — create a new PTY terminal session.
1857
+ */
1858
+ function handleApiPtyCreate(req, res) {
835
1859
  readBody(req, async (err, body) => {
836
- if (err) body = {};
837
-
838
- // clientId is sent by the browser so we can target responses back
839
- const clientId = body?.clientId;
840
-
1860
+ if (err) {
1861
+ sendJson(res, 400, { error: 'Invalid request body' });
1862
+ return;
1863
+ }
841
1864
  try {
842
- const sessionId = await chatSession.createSession({
843
- cwd: body?.cwd || process.cwd(),
844
- pluginDir: body?.pluginDir || options.pluginDir,
845
- onMessage: clientId ? createTargetedSend(clientId) : createTargetedSend('broadcast'),
1865
+ const { sessionId, cwd } = await createPtySession({
1866
+ cwd: body?.cwd,
1867
+ cols: body?.cols,
1868
+ rows: body?.rows,
1869
+ shell: body?.shell,
1870
+ env: body?.env,
846
1871
  });
847
- if (clientId) {
848
- sessionClientMap.set(sessionId, clientId);
849
- }
850
- sendJson(res, 200, { ok: true, sessionId });
851
- } catch (startErr) {
852
- sendJson(res, 500, { error: startErr.message });
1872
+ sendJson(res, 200, { sessionId, cwd });
1873
+ } catch (e) {
1874
+ sendJson(res, 500, { error: e.message });
853
1875
  }
854
1876
  });
855
1877
  }
856
1878
 
857
- function handleApiChatMessage(req, res, chatSession) {
1879
+ /**
1880
+ * POST /api/playground/exec — execute a code snippet and return output.
1881
+ * Supports JavaScript (Node), Python, and Bash.
1882
+ */
1883
+ function handleApiPlaygroundExec(req, res) {
858
1884
  readBody(req, (err, body) => {
859
- if (err || !body?.text) {
860
- sendJson(res, 400, { error: 'Missing "text" in request body' });
1885
+ if (err) {
1886
+ sendJson(res, 400, { error: 'Invalid request body' });
861
1887
  return;
862
1888
  }
863
1889
 
864
- const sessionId = body.sessionId;
865
- if (!sessionId || !chatSession.isActive(sessionId)) {
866
- sendJson(res, 400, { error: 'No active chat session. Provide a valid "sessionId".' });
1890
+ const { code, language } = body || {};
1891
+ if (typeof code !== 'string') {
1892
+ sendJson(res, 400, { error: 'Missing "code" string in request body' });
867
1893
  return;
868
1894
  }
869
1895
 
870
- sendJson(res, 200, { ok: true });
871
-
872
- chatSession.sendMessage(sessionId, body.text).catch((sendErr) => {
873
- console.error('[chat] sendMessage error:', sendErr.message);
874
- });
875
- });
876
- }
877
-
878
- function handleApiChatStop(req, res, chatSession) {
879
- readBody(req, (err, body) => {
880
- const sessionId = body?.sessionId;
881
- if (!sessionId) {
882
- sendJson(res, 400, { error: 'Missing "sessionId" in request body' });
883
- return;
1896
+ const lang = (language || 'javascript').toLowerCase();
1897
+ let cmd;
1898
+ switch (lang) {
1899
+ case 'javascript':
1900
+ case 'js':
1901
+ cmd = `node -e ${JSON.stringify(code)}`;
1902
+ break;
1903
+ case 'python':
1904
+ case 'py':
1905
+ cmd = `python3 -c ${JSON.stringify(code)}`;
1906
+ break;
1907
+ case 'bash':
1908
+ case 'sh':
1909
+ case 'shell':
1910
+ cmd = `sh -c ${JSON.stringify(code)}`;
1911
+ break;
1912
+ case 'typescript':
1913
+ case 'ts':
1914
+ cmd = `node -e ${JSON.stringify(code)}`;
1915
+ break;
1916
+ default:
1917
+ sendJson(res, 400, { error: `Unsupported language: ${lang}` });
1918
+ return;
884
1919
  }
885
- chatSession.stop(sessionId).then(() => {
886
- sendJson(res, 200, { ok: true });
887
- }).catch((stopErr) => {
888
- sendJson(res, 500, { error: stopErr.message });
1920
+
1921
+ exec(cmd, {
1922
+ timeout: PLAYGROUND_EXEC_TIMEOUT_MS,
1923
+ maxBuffer: PLAYGROUND_MAX_OUTPUT_BYTES,
1924
+ env: { ...process.env, NO_COLOR: '1' },
1925
+ }, (error, stdout, stderr) => {
1926
+ let output = '';
1927
+ if (stdout) output += stdout;
1928
+ if (stderr) output += (output ? '\n' : '') + stderr;
1929
+ if (error && !stdout && !stderr) {
1930
+ output = error.killed
1931
+ ? `Execution timed out after ${PLAYGROUND_EXEC_TIMEOUT_MS / 1000}s`
1932
+ : error.message;
1933
+ }
1934
+ const exitCode = error ? (error.code ?? 1) : 0;
1935
+ sendJson(res, 200, { output, exitCode, language: lang });
889
1936
  });
890
1937
  });
891
1938
  }
892
1939
 
893
- function handleApiChatResume(req, res, chatSession, sessionClientMap, createTargetedSend, options) {
1940
+ /**
1941
+ * POST /api/workshop/setup — set up a workshop scenario.
1942
+ * Creates a PTY session, writes files, and optionally runs setup commands.
1943
+ */
1944
+ function handleApiWorkshopSetup(req, res, router) {
894
1945
  readBody(req, async (err, body) => {
895
- if (err || !body?.sessionId) {
896
- sendJson(res, 400, { error: 'Missing "sessionId" in request body' });
1946
+ if (err) {
1947
+ sendJson(res, 400, { error: 'Invalid request body' });
897
1948
  return;
898
1949
  }
899
1950
 
900
- const clientId = body?.clientId;
901
-
902
1951
  try {
903
- await chatSession.resumeSession(body.sessionId, {
904
- cwd: body?.cwd || process.cwd(),
905
- pluginDir: body?.pluginDir || options.pluginDir,
906
- onMessage: clientId ? createTargetedSend(clientId) : createTargetedSend('broadcast'),
1952
+ const { cwd, files, setup, cols, rows } = body || {};
1953
+
1954
+ const { sessionId, cwd: sessionCwd } = await createPtySession({
1955
+ cwd,
1956
+ cols: cols || 120,
1957
+ rows: rows || 30,
907
1958
  });
908
- if (clientId) {
909
- sessionClientMap.set(body.sessionId, clientId);
1959
+
1960
+ if (files && Array.isArray(files)) {
1961
+ await writeSessionFiles(sessionId, files);
910
1962
  }
911
- sendJson(res, 200, { ok: true, sessionId: body.sessionId });
912
- } catch (resumeErr) {
913
- sendJson(res, 500, { error: resumeErr.message });
1963
+
1964
+ if (setup && Array.isArray(setup)) {
1965
+ runSetupCommands(sessionId, setup);
1966
+ }
1967
+
1968
+ sendJson(res, 200, { sessionId, cwd: sessionCwd });
1969
+ } catch (e) {
1970
+ sendJson(res, 500, { error: e.message });
914
1971
  }
915
1972
  });
916
1973
  }
917
1974
 
918
- async function handleApiChatSessions(res, chatSession) {
919
- try {
920
- const sessions = await chatSession.listSessions(process.cwd());
921
- sendJson(res, 200, { sessions });
922
- } catch (listErr) {
923
- sendJson(res, 500, { error: listErr.message });
924
- }
925
- }
926
-
927
1975
  // --- Helpers ---
928
1976
 
929
1977
  function readBody(req, callback) {
@@ -956,8 +2004,212 @@ function ensureDir(dirPath) {
956
2004
  }
957
2005
  }
958
2006
 
2007
+ /**
2008
+ * Enrich progress modules with metadata (title, description, difficulty)
2009
+ * from the module catalog. Modules in progress get their metadata merged in;
2010
+ * catalog modules not yet started are included as status: 'available'.
2011
+ * @param {object} progressModules - { [slug]: { status, xp_earned, ... } }
2012
+ * @param {Map} moduleMap - from buildModuleMap()
2013
+ * @returns {object} enriched modules object keyed by slug
2014
+ */
2015
+ export function enrichModulesWithMetadata(progressModules, moduleMap) {
2016
+ const enriched = {};
2017
+
2018
+ for (const [slug, mod] of Object.entries(progressModules || {})) {
2019
+ const meta = moduleMap.get(slug);
2020
+ enriched[slug] = {
2021
+ ...mod,
2022
+ title: meta?.title || slug,
2023
+ description: meta?.description || '',
2024
+ difficulty: meta?.difficulty || 'beginner',
2025
+ };
2026
+ }
2027
+
2028
+ for (const [slug, meta] of moduleMap) {
2029
+ if (!enriched[slug]) {
2030
+ enriched[slug] = {
2031
+ status: 'available',
2032
+ title: meta.title || slug,
2033
+ description: meta.description || '',
2034
+ difficulty: meta.difficulty || 'beginner',
2035
+ };
2036
+ }
2037
+ }
2038
+
2039
+ return enriched;
2040
+ }
2041
+
2042
+ /**
2043
+ * Parse a .socrates-progress.yaml file without js-yaml.
2044
+ * Handles the 2-level nested structure: top-level sections (user, modules, journey)
2045
+ * with flat key-value children, plus modules having per-slug sub-objects.
2046
+ */
2047
+ export function parseProgressYaml(raw) {
2048
+ const result = { user: { xp: 0, belt: 'white', modules_completed: 0 }, modules: {}, journey: { active: null, progress: {} } };
2049
+ if (!raw) return result;
2050
+
2051
+ const lines = raw.split('\n');
2052
+ let section = null; // 'user', 'modules', 'journey'
2053
+ let subKey = null; // module slug or journey progress key
2054
+
2055
+ for (const line of lines) {
2056
+ if (line.startsWith('#') || line.trim() === '') continue;
2057
+
2058
+ // Top-level section (no indent)
2059
+ const sectionMatch = line.match(/^(\w[\w_-]*):\s*(.*)$/);
2060
+ if (sectionMatch && !line.startsWith(' ') && !line.startsWith('\t')) {
2061
+ section = sectionMatch[1];
2062
+ subKey = null;
2063
+ const val = sectionMatch[2].trim();
2064
+ if (val && section !== 'user' && section !== 'modules' && section !== 'journey' && section !== 'achievements') {
2065
+ result[section] = parseYamlValue(val);
2066
+ }
2067
+ continue;
2068
+ }
2069
+
2070
+ // 2-space or 4-space indented content
2071
+ const indent2 = line.match(/^ (\w[\w_-]*):\s*(.*)$/);
2072
+ if (indent2) {
2073
+ const key = indent2[1];
2074
+ const val = indent2[2].trim();
2075
+
2076
+ if (section === 'user') {
2077
+ result.user[key] = parseYamlValue(val);
2078
+ subKey = null;
2079
+ } else if (section === 'modules') {
2080
+ if (!val) {
2081
+ subKey = key;
2082
+ result.modules[key] = result.modules[key] || {};
2083
+ } else {
2084
+ subKey = key;
2085
+ result.modules[key] = result.modules[key] || {};
2086
+ }
2087
+ } else if (section === 'journey') {
2088
+ if (key === 'active') {
2089
+ result.journey.active = val === 'null' || val === '' ? null : parseYamlValue(val);
2090
+ } else if (key === 'progress' && !val) {
2091
+ subKey = 'progress';
2092
+ }
2093
+ }
2094
+ continue;
2095
+ }
2096
+
2097
+ // 4-space indented (module fields or journey progress fields)
2098
+ const indent4 = line.match(/^ (\w[\w_-]*):\s*(.*)$/);
2099
+ if (indent4 && subKey) {
2100
+ const key = indent4[1];
2101
+ const val = indent4[2].trim();
2102
+ if (section === 'modules' && result.modules[subKey]) {
2103
+ result.modules[subKey][key] = parseYamlValue(val);
2104
+ } else if (section === 'journey' && subKey === 'progress') {
2105
+ result.journey.progress[key] = parseYamlValue(val);
2106
+ }
2107
+ }
2108
+ }
2109
+
2110
+ return result;
2111
+ }
2112
+
2113
+ function parseYamlValue(val) {
2114
+ if (val === 'null' || val === '~' || val === '') return null;
2115
+ if (val === 'true') return true;
2116
+ if (val === 'false') return false;
2117
+ if (/^-?\d+$/.test(val)) return parseInt(val, 10);
2118
+ if (/^-?\d+\.\d+$/.test(val)) return parseFloat(val);
2119
+ if (val.startsWith("'") && val.endsWith("'")) return val.slice(1, -1);
2120
+ if (val.startsWith('"') && val.endsWith('"')) return val.slice(1, -1);
2121
+ return val;
2122
+ }
2123
+
2124
+ /**
2125
+ * Serialize a progress object back to YAML (simple 2-level nesting).
2126
+ */
2127
+ export function serializeProgressYaml(progress) {
2128
+ const lines = [];
2129
+
2130
+ if (progress.user) {
2131
+ lines.push('user:');
2132
+ for (const [k, v] of Object.entries(progress.user)) {
2133
+ lines.push(` ${k}: ${formatYamlValue(v)}`);
2134
+ }
2135
+ }
2136
+
2137
+ if (progress.modules && Object.keys(progress.modules).length > 0) {
2138
+ lines.push('modules:');
2139
+ for (const [slug, mod] of Object.entries(progress.modules)) {
2140
+ lines.push(` ${slug}:`);
2141
+ for (const [k, v] of Object.entries(mod)) {
2142
+ lines.push(` ${k}: ${formatYamlValue(v)}`);
2143
+ }
2144
+ }
2145
+ } else {
2146
+ lines.push('modules: {}');
2147
+ }
2148
+
2149
+ if (progress.journey) {
2150
+ lines.push('journey:');
2151
+ lines.push(` active: ${formatYamlValue(progress.journey.active)}`);
2152
+ if (progress.journey.progress && Object.keys(progress.journey.progress).length > 0) {
2153
+ lines.push(' progress:');
2154
+ for (const [k, v] of Object.entries(progress.journey.progress)) {
2155
+ lines.push(` ${k}: ${formatYamlValue(v)}`);
2156
+ }
2157
+ } else {
2158
+ lines.push(' progress: {}');
2159
+ }
2160
+ }
2161
+
2162
+ return lines.join('\n') + '\n';
2163
+ }
2164
+
2165
+ function formatYamlValue(v) {
2166
+ if (v === null || v === undefined) return 'null';
2167
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
2168
+ if (typeof v === 'number') return String(v);
2169
+ if (typeof v === 'string' && /[:#{}[\],&*?|>!%@`]/.test(v)) return `"${v}"`;
2170
+ return String(v);
2171
+ }
2172
+
2173
+ /**
2174
+ * Create a progressProvider that reads/writes .socrates-progress.yaml directly.
2175
+ * Used in standalone bridge mode when no CLI-injected provider is available.
2176
+ */
2177
+ export function createFileProgressProvider(filePath) {
2178
+ return {
2179
+ getProgress() {
2180
+ try {
2181
+ if (!existsSync(filePath)) {
2182
+ return { user: { xp: 0, belt: 'white', modules_completed: 0 }, modules: {}, journey: { active: null, progress: {} } };
2183
+ }
2184
+ const raw = readFileSync(filePath, 'utf-8');
2185
+ return parseProgressYaml(raw);
2186
+ } catch {
2187
+ return { user: { xp: 0, belt: 'white', modules_completed: 0 }, modules: {}, journey: { active: null, progress: {} } };
2188
+ }
2189
+ },
2190
+ saveProgress(progress) {
2191
+ try {
2192
+ writeFileSync(filePath, serializeProgressYaml(progress), 'utf-8');
2193
+ } catch (err) {
2194
+ console.error('[bridge] Failed to save progress:', err.message);
2195
+ }
2196
+ },
2197
+ };
2198
+ }
2199
+
959
2200
  // --- Standalone execution ---
960
2201
  if (process.argv[1] && process.argv[1].includes('bridge/src/server.js')) {
2202
+ // Prevent unhandled errors from crashing the server
2203
+ process.on('uncaughtException', (err) => {
2204
+ console.error('[bridge] Uncaught exception (server kept alive):', err.message);
2205
+ });
2206
+ process.on('unhandledRejection', (err) => {
2207
+ console.error('[bridge] Unhandled rejection (server kept alive):', err?.message || err);
2208
+ });
2209
+
961
2210
  const port = parseInt(process.env.PORT || process.argv[2] || DEFAULT_PORT, 10);
962
- startServer({ port });
2211
+ const pluginDir = resolve(__dirname, '..', '..', 'plugin');
2212
+ const progressFile = resolve(process.cwd(), '.socrates-progress.yaml');
2213
+ const progressProvider = createFileProgressProvider(progressFile);
2214
+ startServer({ port, pluginDir, progressProvider });
963
2215
  }