learnforge 0.0.0-stage → 0.2.0

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 (464) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +142 -2
  4. package/demo/sample/README.md +8 -0
  5. package/demo/sample/src/backoff.ts +6 -0
  6. package/demo/sample/src/index.ts +12 -0
  7. package/demo/sample/src/queue.ts +58 -0
  8. package/demo/sample/src/worker.ts +20 -0
  9. package/dist/cli.js +3549 -0
  10. package/dist/web/assets/Map-CWWYq8VB.js +1 -0
  11. package/dist/web/assets/Practice-BrxN0TfX.js +25 -0
  12. package/dist/web/assets/_baseUniq-D3A4Knms.js +1 -0
  13. package/dist/web/assets/abap-DsBKuouk.js +1 -0
  14. package/dist/web/assets/actionscript-3-D_z4Izcz.js +1 -0
  15. package/dist/web/assets/ada-727ZlQH0.js +1 -0
  16. package/dist/web/assets/andromeeda-C3khCPGq.js +1 -0
  17. package/dist/web/assets/angular-html-LfdN0zeE.js +1 -0
  18. package/dist/web/assets/angular-ts-CKsD7JZE.js +1 -0
  19. package/dist/web/assets/apache-Dn00JSTd.js +1 -0
  20. package/dist/web/assets/apex-COJ4H7py.js +1 -0
  21. package/dist/web/assets/apl-B4CMkyY2.js +1 -0
  22. package/dist/web/assets/apl-BBq3IX1j.js +1 -0
  23. package/dist/web/assets/applescript-Bu5BbsvL.js +1 -0
  24. package/dist/web/assets/ara-7O62HKoU.js +1 -0
  25. package/dist/web/assets/architectureDiagram-UYN6MBPD-CtGxP8ms.js +36 -0
  26. package/dist/web/assets/asciiarmor-Df11BRmG.js +1 -0
  27. package/dist/web/assets/asciidoc-BPT9niGB.js +1 -0
  28. package/dist/web/assets/asm-Dhn9LcZ4.js +1 -0
  29. package/dist/web/assets/asn1-EdZsLKOL.js +1 -0
  30. package/dist/web/assets/asterisk-B-8jnY81.js +1 -0
  31. package/dist/web/assets/astro-CqkE3fuf.js +1 -0
  32. package/dist/web/assets/aurora-x-D-2ljcwZ.js +1 -0
  33. package/dist/web/assets/awk-eg146-Ew.js +1 -0
  34. package/dist/web/assets/ayu-dark-Cv9koXgw.js +1 -0
  35. package/dist/web/assets/ballerina-Du268qiB.js +1 -0
  36. package/dist/web/assets/bat-fje9CFhw.js +1 -0
  37. package/dist/web/assets/beancount-BwXTMy5W.js +1 -0
  38. package/dist/web/assets/berry-3xVqZejG.js +1 -0
  39. package/dist/web/assets/bibtex-xW4inM5L.js +1 -0
  40. package/dist/web/assets/bicep-DHo0CJ0O.js +1 -0
  41. package/dist/web/assets/blade-a8OxSdnT.js +1 -0
  42. package/dist/web/assets/blockDiagram-ZHA2E4KO-CQ2sgLCo.js +121 -0
  43. package/dist/web/assets/brainfuck-C4LP7Hcl.js +1 -0
  44. package/dist/web/assets/bsl-Dgyn0ogV.js +1 -0
  45. package/dist/web/assets/c-C3t2pwGQ.js +1 -0
  46. package/dist/web/assets/c4Diagram-6F5ED5ID-CWKXGBe-.js +10 -0
  47. package/dist/web/assets/cadence-DNquZEk8.js +1 -0
  48. package/dist/web/assets/cairo--RitsXJZ.js +1 -0
  49. package/dist/web/assets/catppuccin-frappe-CD_QflpE.js +1 -0
  50. package/dist/web/assets/catppuccin-latte-DRW-0cLl.js +1 -0
  51. package/dist/web/assets/catppuccin-macchiato-C-_shW-Y.js +1 -0
  52. package/dist/web/assets/catppuccin-mocha-LGGdnPYs.js +1 -0
  53. package/dist/web/assets/channel-Dh2DBgYr.js +1 -0
  54. package/dist/web/assets/chunk-5HRBRIJM-DFVbwlkG.js +1 -0
  55. package/dist/web/assets/chunk-7U56Z5CX-gak83OWB.js +220 -0
  56. package/dist/web/assets/chunk-ASOPGD6M-cNXbP4zQ.js +1 -0
  57. package/dist/web/assets/chunk-KFBOBJHC-yO5uhU2i.js +1 -0
  58. package/dist/web/assets/chunk-T2TOU4HS-BbXMDtlZ.js +164 -0
  59. package/dist/web/assets/chunk-TMUBEWPD-CxPE7zW_.js +1 -0
  60. package/dist/web/assets/clarity-BHOwM8T6.js +1 -0
  61. package/dist/web/assets/classDiagram-LNE6IOMH-BiiTLJy-.js +1 -0
  62. package/dist/web/assets/classDiagram-v2-MQ7JQ4JX-BiiTLJy-.js +1 -0
  63. package/dist/web/assets/clike-B9uivgTg.js +1 -0
  64. package/dist/web/assets/clojure-BMjYHr_A.js +1 -0
  65. package/dist/web/assets/clojure-DxSadP1t.js +1 -0
  66. package/dist/web/assets/clone-XVPeJFtd.js +1 -0
  67. package/dist/web/assets/cmake-BQqOBYOt.js +1 -0
  68. package/dist/web/assets/cmake-DbXoA79R.js +1 -0
  69. package/dist/web/assets/cobol-CWcv1MsR.js +1 -0
  70. package/dist/web/assets/cobol-PTqiYgYu.js +1 -0
  71. package/dist/web/assets/codeowners-Bp6g37R7.js +1 -0
  72. package/dist/web/assets/codeql-sacFqUAJ.js +1 -0
  73. package/dist/web/assets/coffee-dyiR41kL.js +1 -0
  74. package/dist/web/assets/coffeescript-S37ZYGWr.js +1 -0
  75. package/dist/web/assets/common-lisp-C7gG9l05.js +1 -0
  76. package/dist/web/assets/commonlisp-DBKNyK5s.js +1 -0
  77. package/dist/web/assets/coq-Dsg_Bt_b.js +1 -0
  78. package/dist/web/assets/cpp-BksuvNSY.js +1 -0
  79. package/dist/web/assets/crystal-DtDmRg-F.js +1 -0
  80. package/dist/web/assets/crystal-SjHAIU92.js +1 -0
  81. package/dist/web/assets/csharp-D9R-vmeu.js +1 -0
  82. package/dist/web/assets/css-BPhBrDlE.js +1 -0
  83. package/dist/web/assets/css-BnMrqG3P.js +1 -0
  84. package/dist/web/assets/csv-B0qRVHPH.js +1 -0
  85. package/dist/web/assets/cue-DtFQj3wx.js +1 -0
  86. package/dist/web/assets/cypher-C_CwsFkJ.js +1 -0
  87. package/dist/web/assets/cypher-m2LEI-9-.js +1 -0
  88. package/dist/web/assets/cytoscape.esm-DgFJg4rT.js +331 -0
  89. package/dist/web/assets/d-BoXegm-a.js +1 -0
  90. package/dist/web/assets/d-pRatUO7H.js +1 -0
  91. package/dist/web/assets/d3-scale-35hnzshu.js +1 -0
  92. package/dist/web/assets/dagre-4EVJKHTY-CBDUYbRw.js +4 -0
  93. package/dist/web/assets/dark-plus-C3mMm8J8.js +1 -0
  94. package/dist/web/assets/dart-B9wLZaAG.js +1 -0
  95. package/dist/web/assets/dax-ClGRhx96.js +1 -0
  96. package/dist/web/assets/desktop-DEIpsLCJ.js +1 -0
  97. package/dist/web/assets/diagram-QW4FP2JN-C7TmMevO.js +24 -0
  98. package/dist/web/assets/diff-BgYniUM_.js +1 -0
  99. package/dist/web/assets/diff-DbItnlRl.js +1 -0
  100. package/dist/web/assets/docker-COcR7UxN.js +1 -0
  101. package/dist/web/assets/dockerfile-BKs6k2Af.js +1 -0
  102. package/dist/web/assets/dotenv-BjQB5zDj.js +1 -0
  103. package/dist/web/assets/dracula-BzJJZx-M.js +1 -0
  104. package/dist/web/assets/dracula-soft-BXkSAIEj.js +1 -0
  105. package/dist/web/assets/dream-maker-C-nORZOA.js +1 -0
  106. package/dist/web/assets/dtd-DF_7sFjM.js +1 -0
  107. package/dist/web/assets/dylan-DwRh75JA.js +1 -0
  108. package/dist/web/assets/ebnf-CDyGwa7X.js +1 -0
  109. package/dist/web/assets/ecl-Cabwm37j.js +1 -0
  110. package/dist/web/assets/edge-D5gP-w-T.js +1 -0
  111. package/dist/web/assets/eiffel-CnydiIhH.js +1 -0
  112. package/dist/web/assets/elixir-CLiX3zqd.js +1 -0
  113. package/dist/web/assets/elm-CmHSxxaM.js +1 -0
  114. package/dist/web/assets/elm-vLlmbW-K.js +1 -0
  115. package/dist/web/assets/emacs-lisp-BX77sIaO.js +1 -0
  116. package/dist/web/assets/erDiagram-6RL3IURR-BrIhLTf-.js +51 -0
  117. package/dist/web/assets/erb-BYTLMnw6.js +1 -0
  118. package/dist/web/assets/erlang-B-DoSBHF.js +1 -0
  119. package/dist/web/assets/erlang-BNw1qcRV.js +1 -0
  120. package/dist/web/assets/everforest-dark-BgDCqdQA.js +1 -0
  121. package/dist/web/assets/everforest-light-C8M2exoo.js +1 -0
  122. package/dist/web/assets/factor-kuTfRLto.js +1 -0
  123. package/dist/web/assets/fcl-Kvtd6kyn.js +1 -0
  124. package/dist/web/assets/fennel-bCA53EVm.js +1 -0
  125. package/dist/web/assets/fish-w-ucz2PV.js +1 -0
  126. package/dist/web/assets/flowDiagram-7ASYPVHJ-xplSH1V5.js +160 -0
  127. package/dist/web/assets/fluent-Dayu4EKP.js +1 -0
  128. package/dist/web/assets/forth-Ffai-XNe.js +1 -0
  129. package/dist/web/assets/fortran-DYz_wnZ1.js +1 -0
  130. package/dist/web/assets/fortran-fixed-form-TqA4NnZg.js +1 -0
  131. package/dist/web/assets/fortran-free-form-DKXYxT9g.js +1 -0
  132. package/dist/web/assets/fsharp-XplgxFYe.js +1 -0
  133. package/dist/web/assets/ganttDiagram-NTVNEXSI-CTjkBoVW.js +257 -0
  134. package/dist/web/assets/gas-Bneqetm1.js +1 -0
  135. package/dist/web/assets/gdresource-BHYsBjWJ.js +1 -0
  136. package/dist/web/assets/gdscript-DfxzS6Rs.js +1 -0
  137. package/dist/web/assets/gdshader-SKMF96pI.js +1 -0
  138. package/dist/web/assets/genie-ajMbGru0.js +1 -0
  139. package/dist/web/assets/gherkin--30QC5Em.js +1 -0
  140. package/dist/web/assets/gherkin-heZmZLOM.js +1 -0
  141. package/dist/web/assets/git-commit-i4q6IMui.js +1 -0
  142. package/dist/web/assets/git-rebase-B-v9cOL2.js +1 -0
  143. package/dist/web/assets/gitGraph-YCYPL57B-CMCcQH9Z.js +127 -0
  144. package/dist/web/assets/gitGraphDiagram-NRZ2UAAF-I2RXCZiV.js +65 -0
  145. package/dist/web/assets/github-dark-DHJKELXO.js +1 -0
  146. package/dist/web/assets/github-dark-default-Cuk6v7N8.js +1 -0
  147. package/dist/web/assets/github-dark-dimmed-DH5Ifo-i.js +1 -0
  148. package/dist/web/assets/github-dark-high-contrast-E3gJ1_iC.js +1 -0
  149. package/dist/web/assets/github-light-DAi9KRSo.js +1 -0
  150. package/dist/web/assets/github-light-default-D7oLnXFd.js +1 -0
  151. package/dist/web/assets/github-light-high-contrast-BfjtVDDH.js +1 -0
  152. package/dist/web/assets/gleam-B430Bg39.js +1 -0
  153. package/dist/web/assets/glimmer-js-D-cwc0-E.js +1 -0
  154. package/dist/web/assets/glimmer-ts-pgjy16dm.js +1 -0
  155. package/dist/web/assets/glsl-DBO2IWDn.js +1 -0
  156. package/dist/web/assets/gnuplot-CM8KxXT1.js +1 -0
  157. package/dist/web/assets/go-B1SYOhNW.js +1 -0
  158. package/dist/web/assets/graph-DfxUDGeH.js +1 -0
  159. package/dist/web/assets/graphql-cDcHW_If.js +1 -0
  160. package/dist/web/assets/groovy-D9Dt4D0W.js +1 -0
  161. package/dist/web/assets/groovy-DkBy-JyN.js +1 -0
  162. package/dist/web/assets/hack-D1yCygmZ.js +1 -0
  163. package/dist/web/assets/haml-B2EZWmdv.js +1 -0
  164. package/dist/web/assets/handlebars-BQGss363.js +1 -0
  165. package/dist/web/assets/haskell-BILxekzW.js +1 -0
  166. package/dist/web/assets/haskell-BWDZoCOh.js +1 -0
  167. package/dist/web/assets/haxe-C5wWYbrZ.js +1 -0
  168. package/dist/web/assets/haxe-H-WmDvRZ.js +1 -0
  169. package/dist/web/assets/hcl-HzYwdGDm.js +1 -0
  170. package/dist/web/assets/hjson-T-Tgc4AT.js +1 -0
  171. package/dist/web/assets/hlsl-ifBTmRxC.js +1 -0
  172. package/dist/web/assets/houston-DnULxvSX.js +1 -0
  173. package/dist/web/assets/html-C2L_23MC.js +1 -0
  174. package/dist/web/assets/html-derivative-CSfWNPLT.js +1 -0
  175. package/dist/web/assets/http-DBlCnlav.js +1 -0
  176. package/dist/web/assets/http-FRrOvY1W.js +1 -0
  177. package/dist/web/assets/hxml-TIA70rKU.js +1 -0
  178. package/dist/web/assets/hy-BMj5Y0dO.js +1 -0
  179. package/dist/web/assets/idl-BEugSyMb.js +1 -0
  180. package/dist/web/assets/imba-bv_oIlVt.js +1 -0
  181. package/dist/web/assets/index-4dQXSykb.js +1 -0
  182. package/dist/web/assets/index-BCcuEbgc.js +1 -0
  183. package/dist/web/assets/index-BN9_IkPV.js +1 -0
  184. package/dist/web/assets/index-BbAVe1TP.js +6 -0
  185. package/dist/web/assets/index-Bi0iU3Po.js +3 -0
  186. package/dist/web/assets/index-Biw9rLAa.css +1 -0
  187. package/dist/web/assets/index-BoyLgAJJ.js +13 -0
  188. package/dist/web/assets/index-BqXQJR28.js +1 -0
  189. package/dist/web/assets/index-BwoEv7R1.js +493 -0
  190. package/dist/web/assets/index-BzKIqhZn.js +1 -0
  191. package/dist/web/assets/index-C2PIUqbt.js +1 -0
  192. package/dist/web/assets/index-C7DbozeE.js +7 -0
  193. package/dist/web/assets/index-CDpPKe18.js +1 -0
  194. package/dist/web/assets/index-CTkcLj9F.js +1 -0
  195. package/dist/web/assets/index-CWD7Tiz8.js +1 -0
  196. package/dist/web/assets/index-Cm8ah4T0.js +1 -0
  197. package/dist/web/assets/index-D-vErHI8.js +1 -0
  198. package/dist/web/assets/index-D3sNRgEi.js +1 -0
  199. package/dist/web/assets/index-D91ZBf2I.js +1 -0
  200. package/dist/web/assets/index-D94n50FM.js +2 -0
  201. package/dist/web/assets/index-D9KG0kJs.js +1 -0
  202. package/dist/web/assets/index-DdahRhN3.js +1 -0
  203. package/dist/web/assets/index-Dl9E7_vZ.js +1 -0
  204. package/dist/web/assets/index-DnmHf2in.js +149 -0
  205. package/dist/web/assets/infoDiagram-A4XQUW5V-BcjfRCXi.js +2 -0
  206. package/dist/web/assets/ini-BjABl1g7.js +1 -0
  207. package/dist/web/assets/java-xI-RfyKK.js +1 -0
  208. package/dist/web/assets/javascript-qCveANmP.js +1 -0
  209. package/dist/web/assets/javascript-ySlJ1b_l.js +1 -0
  210. package/dist/web/assets/jetbrains-mono-cyrillic-wght-normal-BptiBwAt.woff2 +0 -0
  211. package/dist/web/assets/jetbrains-mono-greek-wght-normal-BW1JPxja.woff2 +0 -0
  212. package/dist/web/assets/jetbrains-mono-latin-ext-wght-normal-Com2F4yK.woff2 +0 -0
  213. package/dist/web/assets/jetbrains-mono-latin-wght-normal-Ajscav9D.woff2 +0 -0
  214. package/dist/web/assets/jetbrains-mono-vietnamese-wght-normal-By-yputv.woff2 +0 -0
  215. package/dist/web/assets/jinja-DGy0s7-h.js +1 -0
  216. package/dist/web/assets/jinja2-C4DGRd-O.js +1 -0
  217. package/dist/web/assets/jison-BqZprYcd.js +1 -0
  218. package/dist/web/assets/journeyDiagram-G5WM74LC-BrvwR6gO.js +139 -0
  219. package/dist/web/assets/json-BQoSv7ci.js +1 -0
  220. package/dist/web/assets/json5-w8dY5SsB.js +1 -0
  221. package/dist/web/assets/jsonc-TU54ms6u.js +1 -0
  222. package/dist/web/assets/jsonl-DREVFZK8.js +1 -0
  223. package/dist/web/assets/jsonnet-BfivnA6A.js +1 -0
  224. package/dist/web/assets/jssm-P4WzXJd0.js +1 -0
  225. package/dist/web/assets/jsx-BAng5TT0.js +1 -0
  226. package/dist/web/assets/julia-BBuGR-5E.js +1 -0
  227. package/dist/web/assets/julia-DuME0IfC.js +1 -0
  228. package/dist/web/assets/kanagawa-dragon-CkXjmgJE.js +1 -0
  229. package/dist/web/assets/kanagawa-lotus-CfQXZHmo.js +1 -0
  230. package/dist/web/assets/kanagawa-wave-DWedfzmr.js +1 -0
  231. package/dist/web/assets/kanban-definition-QRCXZQQD-Ds9WLUQu.js +88 -0
  232. package/dist/web/assets/katex-C5jXJg4s.js +257 -0
  233. package/dist/web/assets/kotlin-B5lbUyaz.js +1 -0
  234. package/dist/web/assets/kusto-mebxcVVE.js +1 -0
  235. package/dist/web/assets/laserwave-DUszq2jm.js +1 -0
  236. package/dist/web/assets/latex-C-cWTeAZ.js +1 -0
  237. package/dist/web/assets/layout-ktpd0Z4b.js +1 -0
  238. package/dist/web/assets/lean-XBlWyCtg.js +1 -0
  239. package/dist/web/assets/less-BfCpw3nA.js +1 -0
  240. package/dist/web/assets/light-plus-B7mTdjB0.js +1 -0
  241. package/dist/web/assets/liquid-D3W5UaiH.js +1 -0
  242. package/dist/web/assets/livescript-BwQOo05w.js +1 -0
  243. package/dist/web/assets/log-Cc5clBb7.js +1 -0
  244. package/dist/web/assets/logo-IuBKFhSY.js +1 -0
  245. package/dist/web/assets/lua-CvWAzNxB.js +1 -0
  246. package/dist/web/assets/lua-VAEuO923.js +1 -0
  247. package/dist/web/assets/luau-Du5NY7AG.js +1 -0
  248. package/dist/web/assets/make-Bvotw-X0.js +1 -0
  249. package/dist/web/assets/markdown-UIAJJxZW.js +1 -0
  250. package/dist/web/assets/marko-z0MBrx5-.js +1 -0
  251. package/dist/web/assets/material-theme-D5KoaKCx.js +1 -0
  252. package/dist/web/assets/material-theme-darker-BfHTSMKl.js +1 -0
  253. package/dist/web/assets/material-theme-lighter-B0m2ddpp.js +1 -0
  254. package/dist/web/assets/material-theme-ocean-CyktbL80.js +1 -0
  255. package/dist/web/assets/material-theme-palenight-Csfq5Kiy.js +1 -0
  256. package/dist/web/assets/mathematica-DTrFuWx2.js +1 -0
  257. package/dist/web/assets/matlab-D9-PGadD.js +1 -0
  258. package/dist/web/assets/mbox-CNhZ1qSd.js +1 -0
  259. package/dist/web/assets/mdc-DB_EDNY_.js +1 -0
  260. package/dist/web/assets/mdx-sdHcTMYB.js +1 -0
  261. package/dist/web/assets/mermaid-Ci6OQyBP.js +1 -0
  262. package/dist/web/assets/mermaid.core-BGuQYGS_.js +190 -0
  263. package/dist/web/assets/min-C7naItsZ.js +1 -0
  264. package/dist/web/assets/min-dark-CafNBF8u.js +1 -0
  265. package/dist/web/assets/min-light-CTRr51gU.js +1 -0
  266. package/dist/web/assets/mindmap-definition-GWI6TPTV-BwBiBSTc.js +95 -0
  267. package/dist/web/assets/mipsasm-BC5c_5Pe.js +1 -0
  268. package/dist/web/assets/mirc-CjQqDB4T.js +1 -0
  269. package/dist/web/assets/mllike-CXdrOF99.js +1 -0
  270. package/dist/web/assets/modelica-Dc1JOy9r.js +1 -0
  271. package/dist/web/assets/mojo-Tz6hzZYG.js +1 -0
  272. package/dist/web/assets/monokai-D4h5O-jR.js +1 -0
  273. package/dist/web/assets/move-DB_GagMm.js +1 -0
  274. package/dist/web/assets/mscgen-BA5vi2Kp.js +1 -0
  275. package/dist/web/assets/mumps-BT43cFF4.js +1 -0
  276. package/dist/web/assets/narrat-DLbgOhZU.js +1 -0
  277. package/dist/web/assets/nextflow-B0XVJmRM.js +1 -0
  278. package/dist/web/assets/nginx-D_VnBJ67.js +1 -0
  279. package/dist/web/assets/nginx-DdIZxoE0.js +1 -0
  280. package/dist/web/assets/night-owl-C39BiMTA.js +1 -0
  281. package/dist/web/assets/nim-ZlGxZxc3.js +1 -0
  282. package/dist/web/assets/nix-shcSOmrb.js +1 -0
  283. package/dist/web/assets/nord-Ddv68eIx.js +1 -0
  284. package/dist/web/assets/nsis-LY0VT_u5.js +1 -0
  285. package/dist/web/assets/ntriples-BfvgReVJ.js +1 -0
  286. package/dist/web/assets/nushell-D4Tzg5kh.js +1 -0
  287. package/dist/web/assets/objective-c-Deuh7S70.js +1 -0
  288. package/dist/web/assets/objective-cpp-BUEGK8hf.js +1 -0
  289. package/dist/web/assets/ocaml-BNioltXt.js +1 -0
  290. package/dist/web/assets/octave-Ck1zUtKM.js +1 -0
  291. package/dist/web/assets/one-dark-pro-GBQ2dnAY.js +1 -0
  292. package/dist/web/assets/one-light-PoHY5YXO.js +1 -0
  293. package/dist/web/assets/oz-BzwKVEFT.js +1 -0
  294. package/dist/web/assets/pascal--L3eBynH.js +1 -0
  295. package/dist/web/assets/pascal-JqZropPD.js +1 -0
  296. package/dist/web/assets/perl-CHQXSrWU.js +1 -0
  297. package/dist/web/assets/perl-CdXCOZ3F.js +1 -0
  298. package/dist/web/assets/php-B5ebYQev.js +1 -0
  299. package/dist/web/assets/pieDiagram-YF2LJOPJ-DeXhe8wW.js +30 -0
  300. package/dist/web/assets/pig-CevX1Tat.js +1 -0
  301. package/dist/web/assets/plastic-3e1v2bzS.js +1 -0
  302. package/dist/web/assets/plsql-LKU2TuZ1.js +1 -0
  303. package/dist/web/assets/po-BFLt1xDp.js +1 -0
  304. package/dist/web/assets/poimandres-CS3Unz2-.js +1 -0
  305. package/dist/web/assets/polar-DKykz6zU.js +1 -0
  306. package/dist/web/assets/postcss-B3ZDOciz.js +1 -0
  307. package/dist/web/assets/powerquery-CSHBycmS.js +1 -0
  308. package/dist/web/assets/powershell-BIEUsx6d.js +1 -0
  309. package/dist/web/assets/powershell-CFHJl5sT.js +1 -0
  310. package/dist/web/assets/prisma-B48N-Iqd.js +1 -0
  311. package/dist/web/assets/prolog-BY-TUvya.js +1 -0
  312. package/dist/web/assets/properties-C78fOPTZ.js +1 -0
  313. package/dist/web/assets/proto-zocC4JxJ.js +1 -0
  314. package/dist/web/assets/protobuf-ChK-085T.js +1 -0
  315. package/dist/web/assets/pug-CM9l7STV.js +1 -0
  316. package/dist/web/assets/pug-DukmZTjD.js +1 -0
  317. package/dist/web/assets/puppet-Cza_XSSt.js +1 -0
  318. package/dist/web/assets/puppet-DMA9R1ak.js +1 -0
  319. package/dist/web/assets/purescript-Bg-kzb6g.js +1 -0
  320. package/dist/web/assets/python-BuPzkPfP.js +1 -0
  321. package/dist/web/assets/python-DhUJRlN_.js +1 -0
  322. package/dist/web/assets/q-pXgVlZs6.js +1 -0
  323. package/dist/web/assets/qml-D8XfuvdV.js +1 -0
  324. package/dist/web/assets/qmldir-C8lEn-DE.js +1 -0
  325. package/dist/web/assets/qss-DhMKtDLN.js +1 -0
  326. package/dist/web/assets/quadrantDiagram-OS5C2QUG-qIwp82jR.js +7 -0
  327. package/dist/web/assets/r-CwjWoCRV.js +1 -0
  328. package/dist/web/assets/r-DUYO_cvP.js +1 -0
  329. package/dist/web/assets/racket-CzouJOBO.js +1 -0
  330. package/dist/web/assets/raku-B1bQXN8T.js +1 -0
  331. package/dist/web/assets/razor-CNLDkMZG.js +1 -0
  332. package/dist/web/assets/red-bN70gL4F.js +1 -0
  333. package/dist/web/assets/reg-5LuOXUq_.js +1 -0
  334. package/dist/web/assets/regexp-DWJ3fJO_.js +1 -0
  335. package/dist/web/assets/rel-DJlmqQ1C.js +1 -0
  336. package/dist/web/assets/requirementDiagram-MIRIMTAZ-BMNoO9OJ.js +52 -0
  337. package/dist/web/assets/riscv-QhoSD0DR.js +1 -0
  338. package/dist/web/assets/rose-pine-CmCqftbK.js +1 -0
  339. package/dist/web/assets/rose-pine-dawn-Ds-gbosJ.js +1 -0
  340. package/dist/web/assets/rose-pine-moon-CjDtw9vr.js +1 -0
  341. package/dist/web/assets/rpm-CTu-6PCP.js +1 -0
  342. package/dist/web/assets/rst-4NLicBqY.js +1 -0
  343. package/dist/web/assets/ruby-BK5aLJev.js +1 -0
  344. package/dist/web/assets/ruby-DeZ3UC14.js +1 -0
  345. package/dist/web/assets/rust-Be6lgOlo.js +1 -0
  346. package/dist/web/assets/sankeyDiagram-Y46BX6SQ-BcWhv667.js +8 -0
  347. package/dist/web/assets/sas-B4kiWyti.js +1 -0
  348. package/dist/web/assets/sas-BmTFh92c.js +1 -0
  349. package/dist/web/assets/sass-BJ4Li9vH.js +1 -0
  350. package/dist/web/assets/scala-DQVVAn-B.js +1 -0
  351. package/dist/web/assets/scheme-BJGe-b2p.js +1 -0
  352. package/dist/web/assets/scheme-C41bIUwD.js +1 -0
  353. package/dist/web/assets/scss-C31hgJw-.js +1 -0
  354. package/dist/web/assets/sdbl-BLhTXw86.js +1 -0
  355. package/dist/web/assets/sequenceDiagram-G6AWOVSC-C4AgKjJQ.js +122 -0
  356. package/dist/web/assets/shaderlab-B7qAK45m.js +1 -0
  357. package/dist/web/assets/shell-CjFT_Tl9.js +1 -0
  358. package/dist/web/assets/shellscript-atvbtKCR.js +1 -0
  359. package/dist/web/assets/shellsession-C_rIy8kc.js +1 -0
  360. package/dist/web/assets/sieve-C3Gn_uJK.js +1 -0
  361. package/dist/web/assets/simple-mode-GW_nhZxv.js +1 -0
  362. package/dist/web/assets/slack-dark-BthQWCQV.js +1 -0
  363. package/dist/web/assets/slack-ochin-DqwNpetd.js +1 -0
  364. package/dist/web/assets/smalltalk-CnHTOXQT.js +1 -0
  365. package/dist/web/assets/smalltalk-DkLiglaE.js +1 -0
  366. package/dist/web/assets/snazzy-light-Bw305WKR.js +1 -0
  367. package/dist/web/assets/solarized-dark-DXbdFlpD.js +1 -0
  368. package/dist/web/assets/solarized-light-L9t79GZl.js +1 -0
  369. package/dist/web/assets/solidity-C1w2a3ep.js +1 -0
  370. package/dist/web/assets/solr-DehyRSwq.js +1 -0
  371. package/dist/web/assets/source-serif-4-cyrillic-ext-opsz-italic-Bl6DJqma.woff2 +0 -0
  372. package/dist/web/assets/source-serif-4-cyrillic-ext-opsz-normal-DIwfbPUE.woff2 +0 -0
  373. package/dist/web/assets/source-serif-4-cyrillic-opsz-italic-Dibx14aP.woff2 +0 -0
  374. package/dist/web/assets/source-serif-4-cyrillic-opsz-normal-C0olyEE-.woff2 +0 -0
  375. package/dist/web/assets/source-serif-4-greek-opsz-italic-CMW9dfKg.woff2 +0 -0
  376. package/dist/web/assets/source-serif-4-greek-opsz-normal-DrHU7SY7.woff2 +0 -0
  377. package/dist/web/assets/source-serif-4-latin-ext-opsz-italic-BhUEwDRF.woff2 +0 -0
  378. package/dist/web/assets/source-serif-4-latin-ext-opsz-normal-HoL-AExg.woff2 +0 -0
  379. package/dist/web/assets/source-serif-4-latin-opsz-italic-BOLXpvkj.woff2 +0 -0
  380. package/dist/web/assets/source-serif-4-latin-opsz-normal-BpEBLj1O.woff2 +0 -0
  381. package/dist/web/assets/source-serif-4-vietnamese-opsz-italic-C-k5pZlm.woff2 +0 -0
  382. package/dist/web/assets/source-serif-4-vietnamese-opsz-normal-BOyYyU_V.woff2 +0 -0
  383. package/dist/web/assets/soy-C-lX7w71.js +1 -0
  384. package/dist/web/assets/sparql-DkYu6x3z.js +1 -0
  385. package/dist/web/assets/sparql-bYkjHRlG.js +1 -0
  386. package/dist/web/assets/splunk-Cf8iN4DR.js +1 -0
  387. package/dist/web/assets/spreadsheet-BCZA_wO0.js +1 -0
  388. package/dist/web/assets/sql-COK4E0Yg.js +1 -0
  389. package/dist/web/assets/sql-D0XecflT.js +1 -0
  390. package/dist/web/assets/ssh-config-BknIz3MU.js +1 -0
  391. package/dist/web/assets/stata-DorPZHa4.js +1 -0
  392. package/dist/web/assets/stateDiagram-MAYHULR4-D13_SEW-.js +1 -0
  393. package/dist/web/assets/stateDiagram-v2-4JROLMXI-B54Ohb2s.js +1 -0
  394. package/dist/web/assets/stex-C3f8Ysf7.js +1 -0
  395. package/dist/web/assets/stylus-B533Al4x.js +1 -0
  396. package/dist/web/assets/stylus-BeQkCIfX.js +1 -0
  397. package/dist/web/assets/svelte-MSaWC3Je.js +1 -0
  398. package/dist/web/assets/swift-BSxZ-RaX.js +1 -0
  399. package/dist/web/assets/swift-BzpIVaGY.js +1 -0
  400. package/dist/web/assets/synthwave-84-CbfX1IO0.js +1 -0
  401. package/dist/web/assets/system-verilog-C7L56vO4.js +1 -0
  402. package/dist/web/assets/systemd-CUnW07Te.js +1 -0
  403. package/dist/web/assets/talonscript-C1XDQQGZ.js +1 -0
  404. package/dist/web/assets/tasl-CQjiPCtT.js +1 -0
  405. package/dist/web/assets/tcl-DQ1-QYvQ.js +1 -0
  406. package/dist/web/assets/tcl-DVfN8rqt.js +1 -0
  407. package/dist/web/assets/templ-dwX3ZSMB.js +1 -0
  408. package/dist/web/assets/terraform-BbSNqyBO.js +1 -0
  409. package/dist/web/assets/tex-rYs2v40G.js +1 -0
  410. package/dist/web/assets/textile-CnDTJFAw.js +1 -0
  411. package/dist/web/assets/tiddlywiki-DO-Gjzrf.js +1 -0
  412. package/dist/web/assets/tiki-DGYXhP31.js +1 -0
  413. package/dist/web/assets/timeline-definition-U7ZMHBDA-D1EAZC5t.js +61 -0
  414. package/dist/web/assets/tokyo-night-DBQeEorK.js +1 -0
  415. package/dist/web/assets/toml-Bm5Em-hy.js +1 -0
  416. package/dist/web/assets/toml-CB2ApiWb.js +1 -0
  417. package/dist/web/assets/troff-wAsdV37c.js +1 -0
  418. package/dist/web/assets/ts-tags-CipyTH0X.js +1 -0
  419. package/dist/web/assets/tsv-B_m7g4N7.js +1 -0
  420. package/dist/web/assets/tsx-B6W0miNI.js +1 -0
  421. package/dist/web/assets/ttcn-CfJYG6tj.js +1 -0
  422. package/dist/web/assets/ttcn-cfg-B9xdYoR4.js +1 -0
  423. package/dist/web/assets/turtle-B1tBg_DP.js +1 -0
  424. package/dist/web/assets/turtle-BMR_PYu6.js +1 -0
  425. package/dist/web/assets/twig-NC5TFiHP.js +1 -0
  426. package/dist/web/assets/typescript-Dj6nwHGl.js +1 -0
  427. package/dist/web/assets/typespec-BpWG_bgh.js +1 -0
  428. package/dist/web/assets/typst-BVUVsWT6.js +1 -0
  429. package/dist/web/assets/v-CAQ2eGtk.js +1 -0
  430. package/dist/web/assets/vala-BFOHcciG.js +1 -0
  431. package/dist/web/assets/vb-CdO5JTpU.js +1 -0
  432. package/dist/web/assets/vb-CmGdzxic.js +1 -0
  433. package/dist/web/assets/vbscript-BuJXcnF6.js +1 -0
  434. package/dist/web/assets/velocity-D8B20fx6.js +1 -0
  435. package/dist/web/assets/verilog-C6RDOZhf.js +1 -0
  436. package/dist/web/assets/verilog-CJaU5se_.js +1 -0
  437. package/dist/web/assets/vesper-BEBZ7ncR.js +1 -0
  438. package/dist/web/assets/vhdl-DYoNaHQp.js +1 -0
  439. package/dist/web/assets/vhdl-lSbBsy5d.js +1 -0
  440. package/dist/web/assets/viml-m4uW47V2.js +1 -0
  441. package/dist/web/assets/vitesse-black-Bkuqu6BP.js +1 -0
  442. package/dist/web/assets/vitesse-dark-D0r3Knsf.js +1 -0
  443. package/dist/web/assets/vitesse-light-CVO1_9PV.js +1 -0
  444. package/dist/web/assets/vue-BuYVFjOK.js +1 -0
  445. package/dist/web/assets/vue-html-xdeiXROB.js +1 -0
  446. package/dist/web/assets/vyper-nyqBNV6O.js +1 -0
  447. package/dist/web/assets/wasm-C6j12Q_x.js +1 -0
  448. package/dist/web/assets/wasm-CG6Dc4jp.js +1 -0
  449. package/dist/web/assets/webidl-ZXfAyPTL.js +1 -0
  450. package/dist/web/assets/wenyan-7A4Fjokl.js +1 -0
  451. package/dist/web/assets/wgsl-CB0Krxn9.js +1 -0
  452. package/dist/web/assets/wikitext-DCE3LsBG.js +1 -0
  453. package/dist/web/assets/wolfram-C3FkfJm5.js +1 -0
  454. package/dist/web/assets/xml-e3z08dGr.js +1 -0
  455. package/dist/web/assets/xquery-CQfU5ijd.js +1 -0
  456. package/dist/web/assets/xsl-Dd0NUgwM.js +1 -0
  457. package/dist/web/assets/xychartDiagram-6QU3TZC5-9fhTIPRM.js +7 -0
  458. package/dist/web/assets/yacas-BJ4BC0dw.js +1 -0
  459. package/dist/web/assets/yaml-CVw76BM1.js +1 -0
  460. package/dist/web/assets/z80-Hz9HOZM7.js +1 -0
  461. package/dist/web/assets/zenscript-HnGAYVZD.js +1 -0
  462. package/dist/web/assets/zig-BVz_zdnA.js +1 -0
  463. package/dist/web/index.html +22 -0
  464. package/package.json +80 -4
package/dist/cli.js ADDED
@@ -0,0 +1,3549 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { spawn as spawn2 } from "node:child_process";
5
+ import { existsSync as existsSync6, readFileSync as readFileSync5, statSync as statSync5 } from "node:fs";
6
+ import { tmpdir as tmpdir3 } from "node:os";
7
+ import { dirname as dirname3, extname as extname3, join as join8, resolve as resolve4 } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { serve } from "@hono/node-server";
10
+ import { Hono as Hono2 } from "hono";
11
+
12
+ // src/server/app.ts
13
+ import { existsSync as existsSync5, statSync as statSync4 } from "node:fs";
14
+ import { homedir as homedir2 } from "node:os";
15
+ import { Hono } from "hono";
16
+
17
+ // src/core/agent/index.ts
18
+ import { execFileSync } from "node:child_process";
19
+
20
+ // src/core/agent/cli.ts
21
+ import { spawn } from "node:child_process";
22
+ function runCli(command, args, opts2) {
23
+ return new Promise((resolve5, reject) => {
24
+ const child = spawn(command, args, { cwd: opts2.cwd, env: { ...process.env, ...opts2.env }, stdio: ["pipe", "pipe", "pipe"] });
25
+ let out = "";
26
+ let err = "";
27
+ const timer = setTimeout(() => {
28
+ child.kill("SIGTERM");
29
+ reject(new Error(`model call timed out after ${Math.round(opts2.timeoutMs / 1e3)}s`));
30
+ }, opts2.timeoutMs);
31
+ child.stdout.on("data", (d) => out += d);
32
+ child.stderr.on("data", (d) => err += d);
33
+ child.on("error", (e) => {
34
+ clearTimeout(timer);
35
+ reject(e);
36
+ });
37
+ child.on("close", (code) => {
38
+ clearTimeout(timer);
39
+ if (code === 0) resolve5(out);
40
+ else reject(new Error(`${command} exited ${code}: ${(err || out).trim().slice(-600)}`));
41
+ });
42
+ child.stdin.end(opts2.stdin ?? "");
43
+ });
44
+ }
45
+
46
+ // src/core/agent/claude.ts
47
+ var ClaudeCliAgent = class {
48
+ constructor(command, model, timeoutMs, planModel = "") {
49
+ this.command = command;
50
+ this.model = model;
51
+ this.timeoutMs = timeoutMs;
52
+ this.planModel = planModel;
53
+ }
54
+ name = "Claude Code CLI";
55
+ complete(req) {
56
+ const args = ["-p", "--output-format", "text", "--tools", "Read", "Grep", "Glob", "--append-system-prompt", req.system];
57
+ const model = req.role === "plan" && this.planModel ? this.planModel : this.model;
58
+ if (model) args.push("--model", model);
59
+ return runCli(this.command, args, { cwd: req.cwd, stdin: req.prompt, timeoutMs: this.timeoutMs });
60
+ }
61
+ };
62
+
63
+ // src/core/agent/codex.ts
64
+ import { mkdtempSync, readFileSync } from "node:fs";
65
+ import { tmpdir } from "node:os";
66
+ import { join } from "node:path";
67
+ var CodexCliAgent = class {
68
+ constructor(command, model, timeoutMs) {
69
+ this.command = command;
70
+ this.model = model;
71
+ this.timeoutMs = timeoutMs;
72
+ }
73
+ name = "Codex CLI";
74
+ async complete(req) {
75
+ const out = join(mkdtempSync(join(tmpdir(), "learnforge-codex-")), "last.md");
76
+ const args = ["exec", "--sandbox", "read-only", "--skip-git-repo-check", "--cd", req.cwd, "--output-last-message", out];
77
+ if (this.model) args.push("--model", this.model);
78
+ args.push("-");
79
+ const stdout = await runCli(this.command, args, { cwd: req.cwd, stdin: `${req.system}
80
+
81
+ ---
82
+
83
+ ${req.prompt}`, timeoutMs: this.timeoutMs });
84
+ try {
85
+ return readFileSync(out, "utf8");
86
+ } catch {
87
+ return stdout;
88
+ }
89
+ }
90
+ };
91
+
92
+ // src/core/agent/copilot.ts
93
+ import { mkdtempSync as mkdtempSync2, writeFileSync } from "node:fs";
94
+ import { tmpdir as tmpdir2 } from "node:os";
95
+ import { join as join2 } from "node:path";
96
+ var CopilotCliAgent = class {
97
+ constructor(command, model, timeoutMs) {
98
+ this.command = command;
99
+ this.model = model;
100
+ this.timeoutMs = timeoutMs;
101
+ }
102
+ name = "GitHub Copilot CLI";
103
+ async complete(req) {
104
+ const dir = mkdtempSync2(join2(tmpdir2(), "learnforge-copilot-"));
105
+ const file = join2(dir, "task.md");
106
+ writeFileSync(file, `${req.system}
107
+
108
+ ---
109
+
110
+ ${req.prompt}`);
111
+ const prompt = `Read the file ${file} and do exactly what it says. Reply with the answer only.`;
112
+ const args = ["-p", prompt, "-s", "--no-ask-user", "--allow-tool=read", "--deny-tool=shell", "--deny-tool=write", `--add-dir=${dir}`];
113
+ if (this.model) args.push(`--model=${this.model}`);
114
+ return runCli(this.command, args, { cwd: req.cwd, timeoutMs: this.timeoutMs });
115
+ }
116
+ };
117
+
118
+ // src/core/agent/fake.ts
119
+ var FakeAgent = class {
120
+ name = "fake";
121
+ async complete(req) {
122
+ const task = /^TASK: (\w+)/m.exec(req.system)?.[1] ?? "";
123
+ const file = /=== FILE (\S+)/.exec(req.prompt)?.[1] ?? "file";
124
+ if (process.env.LEARNFORGE_FAKE_FAIL === task) throw new Error(`fake failure for ${task}`);
125
+ const delay = Number(process.env.LEARNFORGE_FAKE_DELAY_MS ?? 0);
126
+ if (delay) await new Promise((r) => setTimeout(r, delay));
127
+ return "```json\n" + JSON.stringify(this.reply(task, file, req.prompt, req.system), null, 2) + "\n```";
128
+ }
129
+ reply(task, file, prompt, system) {
130
+ switch (task) {
131
+ case "lens": {
132
+ const sketch = /SKETCH/.test(prompt) ? { differences: ["Your sketch is close; you missed the error path at line 3."] } : {};
133
+ const traceInput = /TRACE INPUT: (.*)/.exec(prompt)?.[1];
134
+ const trace = /"trace":/.test(system) ? {
135
+ trace: {
136
+ input: traceInput && !traceInput.startsWith("(") ? traceInput : "n = 3",
137
+ steps: [1, 2, 3].map((i) => ({ file, line: i, state: { n: String(3 - i + 1), total: String(i * 2) }, note: `Step ${i}: line ${i} runs.` }))
138
+ }
139
+ } : {};
140
+ return {
141
+ ...sketch,
142
+ ...trace,
143
+ tldr: `The one idea: ${file} turns input into output in a single pass.`,
144
+ whyItMatters: "Without this model you will change the wrong line.",
145
+ gotchas: ["Line 3 runs even when the input is empty."],
146
+ nextSteps: [{ file, why: "Read it again with the steps in mind." }],
147
+ title: `How ${file} works`,
148
+ summary: `This is a fake lesson about ${file}. It shows the shape of a real one.`,
149
+ concepts: [
150
+ { term: "Entry point", explain: "Where the work starts.", example: "Like the front door of a house.", file, line: 1 },
151
+ { term: "Side effect", explain: "A change outside the function, like a file write." }
152
+ ],
153
+ intuition: "Input flows to output:\n\n```mermaid\nflowchart LR\n A[input] --> B[transform] --> C[output]\n```",
154
+ deepDives: [
155
+ { title: "The top of the file sets up what the rest needs", file, line: 1, startLine: 1, endLine: 4, body: "Imports and setup.", check: { q: "What does line 1 bring in?", a: "The types the rest uses." } },
156
+ { title: "The main logic", file, line: 3, startLine: 3, endLine: 8, body: "Where the work happens.", pitfall: "Changing the order here breaks the output." }
157
+ ]
158
+ };
159
+ }
160
+ case "walkPlan":
161
+ return { blocks: [1, 2].map((i) => ({ startLine: i, endLine: i + 2, title: `Block ${i} does one thing`, covers: ["a fact"], example: "n = 3", question: "what happens for n = 3?" })) };
162
+ case "walkBlocks": {
163
+ const m = /WRITE BLOCKS (\d+)-(\d+)/.exec(prompt);
164
+ const [a, b] = m ? [Number(m[1]), Number(m[2])] : [0, 0];
165
+ return {
166
+ blocks: Array.from({ length: b - a + 1 }, (_, k) => ({
167
+ index: a + k,
168
+ why: `Block ${a + k} follows the last one.`,
169
+ explain: `Fake explanation for block ${a + k}.`,
170
+ example: "With `n = 3`:\n\n```\nresult = f(3) # 6\n```",
171
+ question: `Fake question ${a + k}: what does f(3) give?`
172
+ }))
173
+ };
174
+ }
175
+ case "plan":
176
+ return { outline: "1. Start with the entry point.\n2. Cover the main branch.\n3. End with the error path." };
177
+ case "lensPlan":
178
+ return {
179
+ title: `How ${file} works`,
180
+ tldr: `The one idea: ${file} turns input into output in a single pass.`,
181
+ whyItMatters: "Without this model you will change the wrong line.",
182
+ concepts: [{ term: "Entry point", note: "Where the work starts.", file, line: 1 }],
183
+ intuition: { example: "n = 3", diagram: "input to output" },
184
+ steps: [1, 2, 3, 4].map((i) => ({
185
+ title: `Step ${i} is one idea about ${file}`,
186
+ file,
187
+ startLine: i,
188
+ endLine: i + 3,
189
+ line: i,
190
+ covers: [`Fact ${i}`],
191
+ example: "n = 3",
192
+ edge: "",
193
+ why: "It keeps the output stable.",
194
+ pitfall: "",
195
+ check: "what if empty?"
196
+ })),
197
+ gotchas: ["Line 3 runs even when the input is empty."],
198
+ nextSteps: [{ file, why: "Read it again with the steps in mind." }]
199
+ };
200
+ case "lensIntro": {
201
+ const sketch = /SKETCH/.test(prompt) ? { differences: ["Your sketch is close; you missed the error path at line 3."] } : {};
202
+ const traceInput = /TRACE INPUT: (.*)/.exec(prompt)?.[1];
203
+ const trace = /"trace":/.test(system) ? { trace: { input: traceInput && !traceInput.startsWith("(") ? traceInput : "n = 3", steps: [1, 2, 3].map((i) => ({ file, line: i, state: { n: String(i) }, note: `Step ${i}: line ${i} runs.` })) } } : {};
204
+ return {
205
+ ...sketch,
206
+ ...trace,
207
+ summary: `This is a fake lesson about ${file}. It shows the shape of a real one.`,
208
+ concepts: [
209
+ { term: "Entry point", explain: "Where the work starts.", example: "Like the front door of a house.", file, line: 1 },
210
+ { term: "Side effect", explain: "A change outside the function, like a file write." }
211
+ ],
212
+ intuition: "Input flows to output:\n\n```mermaid\nflowchart LR\n A[input] --> B[transform] --> C[output]\n```",
213
+ gotchas: ["Line 3 runs even when the input is empty."],
214
+ nextSteps: [{ file, why: "Read it again with the steps in mind." }]
215
+ };
216
+ }
217
+ case "lensSteps": {
218
+ const m = /WRITE STEPS (\d+)-(\d+)/.exec(prompt);
219
+ const [a, b] = m ? [Number(m[1]), Number(m[2])] : [0, 0];
220
+ return {
221
+ steps: Array.from({ length: b - a + 1 }, (_, k) => ({
222
+ index: a + k,
223
+ body: `Fake body for step ${a + k}. It explains the idea with real values.`,
224
+ pitfall: "Changing the order here breaks the output.",
225
+ check: { q: `What does step ${a + k} do?`, a: "It does the thing." }
226
+ }))
227
+ };
228
+ }
229
+ case "quiz": {
230
+ const n = Number(/exactly (\d+) items/.exec(system)?.[1] ?? 3);
231
+ const weak = [...prompt.matchAll(/^\[(\w+)\] /gm)].map((m) => m[1]);
232
+ return {
233
+ title: weak.length ? "Your weak spots" : `Quiz on ${file}`,
234
+ questions: Array.from({ length: n || 3 }, (_, i) => ({
235
+ targets: weak[i],
236
+ prompt: `Fake question ${i + 1}: what does line ${i + 1} of ${file} do?`,
237
+ file,
238
+ line: i + 1,
239
+ focus: "line purpose"
240
+ }))
241
+ };
242
+ }
243
+ case "grade": {
244
+ const ids = [...prompt.matchAll(/^\[(q\d+)\] Q:/gm)].map((m) => m[1]);
245
+ const answers = [...prompt.matchAll(/^A: (.*)$/gm)].map((m) => m[1]);
246
+ return {
247
+ grades: ids.map((qid, i) => {
248
+ const a = answers[i] ?? "";
249
+ const verdict3 = a.length > 20 ? "right" : a.length > 3 && a !== "(no answer)" ? "partial" : "wrong";
250
+ return {
251
+ qid,
252
+ verdict: verdict3,
253
+ feedback: `Fake grade (${a.length} chars).`,
254
+ modelAnswer: "The model answer.",
255
+ ...verdict3 === "right" ? {} : { misconception: `You think question ${qid} is about names, but it is about behaviour.` }
256
+ };
257
+ })
258
+ };
259
+ }
260
+ case "practice":
261
+ const block = prompt.split("=== FILE ")[1] ?? "";
262
+ const body = [...block.matchAll(/^\s*(\d+)\| (.*)$/gm)].map((m) => ({ n: Number(m[1]), text: m[2] }));
263
+ const real = (t) => !!t.trim() && !/^\s*(\/\/|#|\*|\/\*|import\b|from\b|[{}()\[\];,]+\s*$)/.test(t);
264
+ const at = body.findIndex((l, i) => l.n >= 2 && real(l.text) && real(body[i + 1]?.text ?? ""));
265
+ const start = at >= 0 ? body[at].n : 2;
266
+ return { concept: "Fake concept.", holes: [{ file: /=== FILE (\S+)/.exec(prompt)?.[1], startLine: start, endLine: start + 1, intent: "Do the main step.", hints: ["Look at the names.", "Return the result."] }] };
267
+ case "review": {
268
+ const ids = [...prompt.matchAll(/^hole (\d+):/gm)].map((m) => Number(m[1]));
269
+ return { summary: "Fake review.", holes: ids.map((id) => ({ id, verdict: "partial", feedback: "Close." })) };
270
+ }
271
+ case "teachback": {
272
+ const text2 = /THE LEARNER'S EXPLANATION:\n([\s\S]*?)\n\nCODE:/.exec(prompt)?.[1] ?? "";
273
+ const score = text2.length > 120 ? 4 : text2.length > 40 ? 3 : 2;
274
+ return {
275
+ score,
276
+ summary: "Fake teach-back grade.",
277
+ right: ["You name the main step."],
278
+ missing: ["The error path."],
279
+ wrong: score < 4 ? ["You say it retries, but it gives up after one try (line 3)."] : []
280
+ };
281
+ }
282
+ case "tutor": {
283
+ const tell = /tell them/.test(system);
284
+ return { reply: tell ? "Here is the answer: line 2 does the work. What would change if it were missing?" : "What do you see on line 2? What does it return?" };
285
+ }
286
+ case "walk":
287
+ return {
288
+ blocks: [
289
+ { startLine: 1, endLine: 2, title: "Setup", why: "Where it starts.", question: "What does the first line bring in?" },
290
+ { startLine: 3, endLine: 4, title: "Main step", why: "The work.", question: "What does the main step return?" }
291
+ ]
292
+ };
293
+ case "walkcheck": {
294
+ const a = /^ANSWER: (.*)$/m.exec(prompt)?.[1] ?? "";
295
+ const v = a.length > 20 ? "right" : a.length > 3 && a !== "(no answer)" ? "partial" : "wrong";
296
+ return { verdict: v, feedback: `Fake check (${a.length} chars).`, ...v === "right" ? {} : { misconception: "You think the block sets up state, but it returns a value." } };
297
+ }
298
+ case "hunt": {
299
+ const m = /^\s*(\d+)\| (.*\S.*)$/m.exec(prompt);
300
+ const line2 = Number(m?.[1] ?? 1);
301
+ const original = m?.[2] ?? "";
302
+ return { bugs: [{ line: line2, original, buggy: `${original} // changed`, what: "The fake bug changes the first line." }] };
303
+ }
304
+ case "huntgrade": {
305
+ const found = [...prompt.matchAll(/-> bug line (\d+)/g)].map((m) => ({ line: Number(m[1]), feedback: "Right line, right reason." }));
306
+ const fa = /FALSE ALARMS:\n([\s\S]*?)\n\nTHE FILE/.exec(prompt)?.[1] ?? "";
307
+ const falseAlarms = [...fa.matchAll(/^line (\d+):/gm)].map((m) => ({ line: Number(m[1]), feedback: "This line is fine." }));
308
+ return { found, falseAlarms };
309
+ }
310
+ case "path": {
311
+ const files = (/ALL FILES:\n([\s\S]*?)(\n\n|$)/.exec(prompt)?.[1] ?? "").split("\n").filter(Boolean);
312
+ const pick = files.filter((f) => /\.(ts|js|py|md)$/.test(f)).slice(0, 3);
313
+ return {
314
+ title: "Fake onboarding path",
315
+ stops: pick.map((f, i) => ({ title: `Stop ${i + 1}: ${f}`, files: [f], why: "Fake reason.", activity: i === 2 ? "quiz" : "lens" }))
316
+ };
317
+ }
318
+ default:
319
+ return {};
320
+ }
321
+ }
322
+ };
323
+
324
+ // src/core/agent/sdk.ts
325
+ import Anthropic from "@anthropic-ai/sdk";
326
+ var SdkAgent = class {
327
+ constructor(model, timeoutMs, planModel = "") {
328
+ this.model = model;
329
+ this.planModel = planModel;
330
+ this.client = new Anthropic({ timeout: timeoutMs });
331
+ }
332
+ name = "Anthropic API";
333
+ client;
334
+ async complete(req) {
335
+ const msg = await this.client.messages.create({
336
+ model: req.role === "plan" && this.planModel ? this.planModel : this.model,
337
+ max_tokens: 16e3,
338
+ system: req.system,
339
+ messages: [{ role: "user", content: req.prompt }]
340
+ });
341
+ return msg.content.map((b) => b.type === "text" ? b.text : "").join("");
342
+ }
343
+ };
344
+
345
+ // src/core/agent/index.ts
346
+ function hasCommand(cmd) {
347
+ try {
348
+ execFileSync(cmd, ["--version"], { stdio: "ignore", timeout: 1e4 });
349
+ return true;
350
+ } catch {
351
+ return false;
352
+ }
353
+ }
354
+ function providers(s, timeoutMs) {
355
+ return [
356
+ {
357
+ id: "claude",
358
+ label: "Claude Code CLI",
359
+ available: () => hasCommand(s["agent.claudeCommand"]),
360
+ make: () => new ClaudeCliAgent(s["agent.claudeCommand"], s["agent.model"], timeoutMs, s["agent.planModel"]),
361
+ detail: `${s["agent.claudeCommand"]} -p, model ${s["agent.model"] || "CLI default"}, read-only tools, your Claude Code login`,
362
+ missing: `"${s["agent.claudeCommand"]}" not found. Install Claude Code and run it once to log in.`
363
+ },
364
+ {
365
+ id: "codex",
366
+ label: "Codex CLI",
367
+ available: () => hasCommand(s["agent.codexCommand"]),
368
+ make: () => new CodexCliAgent(s["agent.codexCommand"], s["agent.codexModel"], timeoutMs),
369
+ detail: `${s["agent.codexCommand"]} exec, read-only sandbox, model ${s["agent.codexModel"] || "CLI default"}, your codex login`,
370
+ missing: `"${s["agent.codexCommand"]}" not found. Install it (npm i -g @openai/codex) and run "codex login".`
371
+ },
372
+ {
373
+ id: "copilot",
374
+ label: "GitHub Copilot CLI",
375
+ available: () => hasCommand(s["agent.copilotCommand"]),
376
+ make: () => new CopilotCliAgent(s["agent.copilotCommand"], s["agent.copilotModel"], timeoutMs),
377
+ detail: `${s["agent.copilotCommand"]} -p, read tool only, model ${s["agent.copilotModel"] || "CLI default"}, your GitHub login`,
378
+ missing: `"${s["agent.copilotCommand"]}" not found. Install it (npm i -g @github/copilot) and log in once.`
379
+ },
380
+ {
381
+ id: "sdk",
382
+ label: "Anthropic API",
383
+ available: () => !!process.env.ANTHROPIC_API_KEY,
384
+ make: () => new SdkAgent(s["agent.model"], timeoutMs, s["agent.planModel"]),
385
+ detail: `model ${s["agent.model"]}, key from the ANTHROPIC_API_KEY environment variable`,
386
+ missing: "Set ANTHROPIC_API_KEY in the environment, then restart learnforge."
387
+ }
388
+ ];
389
+ }
390
+ function makeAgent(s) {
391
+ const mode = process.env.LEARNFORGE_AGENT || s["agent.mode"];
392
+ const timeoutMs = s["agent.timeoutSec"] * 1e3;
393
+ const all = providers(s, timeoutMs);
394
+ const available = Object.fromEntries(all.map((p) => [p.id, p.available()]));
395
+ if (mode === "fake") {
396
+ return { agent: new FakeAgent(), info: { mode, name: "fake", ready: true, detail: "Canned answers for tests and demos.", available } };
397
+ }
398
+ const pick = mode === "auto" ? all.find((p) => available[p.id]) : all.find((p) => p.id === mode && available[p.id]);
399
+ if (pick) return { agent: pick.make(), info: { mode, name: pick.label, ready: true, detail: pick.detail, available } };
400
+ const wanted = all.find((p) => p.id === mode);
401
+ const detail = wanted ? wanted.missing : "No model found. Install Claude Code, Codex or Copilot CLI, or set ANTHROPIC_API_KEY, then restart.";
402
+ return { agent: null, info: { mode, name: "none", ready: false, detail, available } };
403
+ }
404
+
405
+ // src/core/insights.ts
406
+ import { createEmptyCard, fsrs, Rating as R } from "ts-fsrs";
407
+
408
+ // src/core/store.ts
409
+ import { createHash, randomBytes } from "node:crypto";
410
+ import { existsSync, mkdirSync, readFileSync as readFileSync2, readdirSync, renameSync, utimesSync, writeFileSync as writeFileSync2 } from "node:fs";
411
+ import { homedir } from "node:os";
412
+ import { join as join3, basename, resolve } from "node:path";
413
+ var DEFAULT_SETTINGS = {
414
+ "agent.mode": "auto",
415
+ "agent.model": "claude-sonnet-5-5",
416
+ "agent.planModel": "claude-opus-5-5",
417
+ "generate.plan": true,
418
+ "agent.claudeCommand": "claude",
419
+ "agent.codexCommand": "codex",
420
+ "agent.codexModel": "",
421
+ "agent.copilotCommand": "copilot",
422
+ "agent.copilotModel": "",
423
+ "agent.timeoutSec": 300,
424
+ "quiz.questions": 4,
425
+ "quiz.passMark": 0.67,
426
+ "context.maxBytes": 16e4,
427
+ "cards.fromMisses": true,
428
+ "progress.fadeDays": 21,
429
+ "notes.inPrompts": false,
430
+ "storage.archiveDays": 30,
431
+ "storage.cacheMB": 50,
432
+ "storage.maxAttempts": 20
433
+ };
434
+ var COLLECTIONS = [
435
+ "lenses",
436
+ "quizzes",
437
+ "practice",
438
+ "cards",
439
+ "misconceptions",
440
+ "teachbacks",
441
+ "threads",
442
+ "walks",
443
+ "hunts",
444
+ "paths",
445
+ "notes"
446
+ ];
447
+ function newId() {
448
+ return Date.now().toString(36) + randomBytes(3).toString("hex");
449
+ }
450
+ function projectIdFor(root) {
451
+ const slug2 = basename(root).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "project";
452
+ return `${slug2}-${createHash("sha1").update(root).digest("hex").slice(0, 6)}`;
453
+ }
454
+ var Store = class {
455
+ home;
456
+ constructor(home = process.env.LEARNFORGE_HOME || join3(homedir(), ".learnforge")) {
457
+ this.home = resolve(home);
458
+ mkdirSync(join3(this.home, "projects"), { recursive: true });
459
+ }
460
+ // ---- low level -------------------------------------------------------
461
+ read(path2, fallback) {
462
+ if (!existsSync(path2)) return fallback;
463
+ try {
464
+ return JSON.parse(readFileSync2(path2, "utf8"));
465
+ } catch {
466
+ return fallback;
467
+ }
468
+ }
469
+ /** Write to a temp file then rename, so a crash never leaves half a file. */
470
+ write(path2, data) {
471
+ const tmp = `${path2}.${process.pid}.tmp`;
472
+ writeFileSync2(tmp, JSON.stringify(data, null, 2));
473
+ renameSync(tmp, path2);
474
+ }
475
+ // ---- settings --------------------------------------------------------
476
+ settings() {
477
+ return { ...DEFAULT_SETTINGS, ...this.read(join3(this.home, "settings.json"), {}) };
478
+ }
479
+ saveSettings(patch) {
480
+ const next = { ...this.settings() };
481
+ for (const [k, v] of Object.entries(patch)) {
482
+ if (k in DEFAULT_SETTINGS && v !== void 0 && v !== null) next[k] = v;
483
+ }
484
+ this.write(join3(this.home, "settings.json"), next);
485
+ return next;
486
+ }
487
+ // ---- projects --------------------------------------------------------
488
+ projects() {
489
+ return this.read(join3(this.home, "projects.json"), []).sort(
490
+ (a, b) => b.openedAt.localeCompare(a.openedAt)
491
+ );
492
+ }
493
+ project(id) {
494
+ return this.projects().find((p) => p.id === id);
495
+ }
496
+ saveProjects(list) {
497
+ this.write(join3(this.home, "projects.json"), list);
498
+ }
499
+ /** Add a folder, or touch it if it is already known. */
500
+ openProject(root, name) {
501
+ const abs = resolve(root);
502
+ const list = this.projects();
503
+ const now2 = (/* @__PURE__ */ new Date()).toISOString();
504
+ let p = list.find((x) => x.root === abs);
505
+ if (p) {
506
+ p.openedAt = now2;
507
+ if (name) p.name = name;
508
+ } else {
509
+ p = { id: projectIdFor(abs), name: name || basename(abs), root: abs, contexts: [], addedAt: now2, openedAt: now2 };
510
+ list.push(p);
511
+ const old = join3(this.home, "archive", "projects", p.id);
512
+ const live = join3(this.home, "projects", p.id);
513
+ if (existsSync(old) && !existsSync(live)) renameSync(old, live);
514
+ }
515
+ this.saveProjects(list);
516
+ return p;
517
+ }
518
+ updateProject(id, patch) {
519
+ const list = this.projects();
520
+ const p = list.find((x) => x.id === id);
521
+ if (!p) return void 0;
522
+ if (patch.name) p.name = patch.name;
523
+ if (patch.contexts) p.contexts = [...new Set(patch.contexts.map((c) => resolve(c)))].filter((c) => c !== p.root);
524
+ this.saveProjects(list);
525
+ return p;
526
+ }
527
+ /**
528
+ * Forget the folder. Its data moves to archive/projects/ and is removed after
529
+ * storage.archiveDays, so forgotten projects do not pile up. Opening the folder again
530
+ * within that time brings the history back.
531
+ */
532
+ forgetProject(id) {
533
+ this.saveProjects(this.projects().filter((p) => p.id !== id));
534
+ if (!/^[a-z0-9-]+$/.test(id)) return;
535
+ const src = join3(this.home, "projects", id);
536
+ if (!existsSync(src)) return;
537
+ const dst = join3(this.home, "archive", "projects");
538
+ mkdirSync(dst, { recursive: true });
539
+ const target = join3(dst, id);
540
+ if (existsSync(target)) renameSync(target, `${target}-${Date.now()}`);
541
+ renameSync(src, target);
542
+ const now2 = /* @__PURE__ */ new Date();
543
+ utimesSync(target, now2, now2);
544
+ }
545
+ // ---- collections -----------------------------------------------------
546
+ dir(projectId, c) {
547
+ if (!/^[a-z0-9-]+$/.test(projectId)) throw new Error("bad project id");
548
+ const d = join3(this.home, "projects", projectId, c);
549
+ mkdirSync(d, { recursive: true });
550
+ return d;
551
+ }
552
+ list(projectId, c) {
553
+ const d = this.dir(projectId, c);
554
+ return readdirSync(d).filter((f) => f.endsWith(".json")).map((f) => this.read(join3(d, f), null)).filter((x) => x !== null).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
555
+ }
556
+ get(projectId, c, id) {
557
+ if (!/^[a-z0-9]+$/.test(id)) return void 0;
558
+ return this.read(join3(this.dir(projectId, c), `${id}.json`), void 0);
559
+ }
560
+ put(projectId, c, item) {
561
+ this.write(join3(this.dir(projectId, c), `${item.id}.json`), item);
562
+ return item;
563
+ }
564
+ /** Move to an archive folder instead of erasing. */
565
+ archive(projectId, c, id) {
566
+ if (!/^[a-z0-9]+$/.test(id)) return false;
567
+ const src = join3(this.dir(projectId, c), `${id}.json`);
568
+ if (!existsSync(src)) return false;
569
+ const dst = join3(this.home, "projects", projectId, "archive", c);
570
+ mkdirSync(dst, { recursive: true });
571
+ const to = join3(dst, `${id}.json`);
572
+ renameSync(src, to);
573
+ const now2 = /* @__PURE__ */ new Date();
574
+ utimesSync(to, now2, now2);
575
+ return true;
576
+ }
577
+ };
578
+
579
+ // src/core/storage.ts
580
+ import { createHash as createHash2 } from "node:crypto";
581
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readdirSync as readdirSync2, rmSync, statSync, utimesSync as utimesSync2 } from "node:fs";
582
+ import { join as join4, sep } from "node:path";
583
+ var DAY = 864e5;
584
+ var CARD_REVIEW_CAP = 50;
585
+ var THREAD_MESSAGE_CAP = 200;
586
+ function walk(dir, each) {
587
+ const out = { bytes: 0, files: 0 };
588
+ if (!existsSync2(dir)) return out;
589
+ for (const e of readdirSync2(dir, { withFileTypes: true })) {
590
+ const p = join4(dir, e.name);
591
+ if (e.isDirectory()) {
592
+ const sub = walk(p, each);
593
+ out.bytes += sub.bytes;
594
+ out.files += sub.files;
595
+ } else if (e.isFile()) {
596
+ const st = statSync(p);
597
+ out.bytes += st.size;
598
+ out.files++;
599
+ each?.(p, st.size, st.mtimeMs);
600
+ }
601
+ }
602
+ return out;
603
+ }
604
+ function usage(store2) {
605
+ const home = store2.home;
606
+ const parts = [];
607
+ const byName = /* @__PURE__ */ new Map();
608
+ const add = (name, w) => {
609
+ const cur = byName.get(name) ?? { bytes: 0, files: 0 };
610
+ byName.set(name, { bytes: cur.bytes + w.bytes, files: cur.files + w.files });
611
+ };
612
+ const projects = join4(home, "projects");
613
+ if (existsSync2(projects)) {
614
+ for (const pid of readdirSync2(projects)) {
615
+ for (const c of [...COLLECTIONS, "archive"]) add(c, walk(join4(projects, pid, c)));
616
+ }
617
+ }
618
+ add("cache", walk(join4(home, "cache")));
619
+ add("forgotten projects", walk(join4(home, "archive")));
620
+ for (const [name, w] of byName) if (w.files) parts.push({ name, ...w });
621
+ parts.sort((a, b) => b.bytes - a.bytes);
622
+ const total = walk(home);
623
+ return { home, totalBytes: total.bytes, parts, lastPrune: store2.read(join4(home, "prune.json"), null) };
624
+ }
625
+ function prune(store2, now2 = Date.now()) {
626
+ const s = store2.settings();
627
+ const home = store2.home;
628
+ let removed = 0;
629
+ let freedBytes = 0;
630
+ const drop = (path2, size, dir = false) => {
631
+ rmSync(path2, { recursive: dir, force: true });
632
+ removed++;
633
+ freedBytes += size;
634
+ };
635
+ walk(home, (p, size, mtime) => {
636
+ if (p.endsWith(".tmp") && now2 - mtime > 36e5) drop(p, size);
637
+ });
638
+ const days = s["storage.archiveDays"];
639
+ if (days > 0) {
640
+ const cutoff = now2 - days * DAY;
641
+ const projects = join4(home, "projects");
642
+ if (existsSync2(projects)) {
643
+ for (const pid of readdirSync2(projects)) {
644
+ walk(join4(projects, pid, "archive"), (p, size, mtime) => mtime < cutoff && drop(p, size));
645
+ }
646
+ }
647
+ const forgotten = join4(home, "archive", "projects");
648
+ if (existsSync2(forgotten)) {
649
+ for (const name of readdirSync2(forgotten)) {
650
+ const p = join4(forgotten, name);
651
+ if (statSync(p).mtimeMs < cutoff) drop(p, walk(p).bytes, true);
652
+ }
653
+ }
654
+ }
655
+ const cap = Math.max(0, s["storage.cacheMB"]) * 1024 * 1024;
656
+ const cached = [];
657
+ const total = walk(join4(home, "cache"), (p, size, mtime) => cached.push({ p, size, mtime })).bytes;
658
+ let over = total - cap;
659
+ for (const c of cached.sort((a, b) => a.mtime - b.mtime)) {
660
+ if (over <= 0) break;
661
+ drop(c.p, c.size);
662
+ over -= c.size;
663
+ }
664
+ const maxAttempts = Math.max(1, s["storage.maxAttempts"]);
665
+ for (const p of store2.projects()) {
666
+ for (const q of store2.list(p.id, "quizzes")) {
667
+ if (q.attempts.length <= maxAttempts) continue;
668
+ const best = q.attempts.reduce((b, a) => a.score > b.score ? a : b);
669
+ const keep = q.attempts.slice(-maxAttempts);
670
+ if (!keep.includes(best)) keep.splice(0, 1, best);
671
+ removed += q.attempts.length - keep.length;
672
+ q.attempts = keep.sort((a, b) => a.at.localeCompare(b.at));
673
+ store2.put(p.id, "quizzes", q);
674
+ }
675
+ }
676
+ store2.write(join4(home, "prune.json"), { at: new Date(now2).toISOString(), removed, freedBytes });
677
+ return { removed, freedBytes };
678
+ }
679
+ var ReplyCache = class {
680
+ constructor(store2) {
681
+ this.store = store2;
682
+ this.dir = join4(store2.home, "cache");
683
+ }
684
+ dir;
685
+ key(...parts) {
686
+ const h = createHash2("sha256");
687
+ for (const p of parts) h.update(p).update("\0");
688
+ return h.digest("hex").slice(0, 32);
689
+ }
690
+ get(key) {
691
+ const p = join4(this.dir, `${key}.json`);
692
+ const hit = this.store.read(p, null);
693
+ if (!hit) return void 0;
694
+ const t = /* @__PURE__ */ new Date();
695
+ utimesSync2(p, t, t);
696
+ return hit.text;
697
+ }
698
+ set(key, text2) {
699
+ mkdirSync2(this.dir, { recursive: true });
700
+ this.store.write(join4(this.dir, `${key}.json`), { text: text2, at: (/* @__PURE__ */ new Date()).toISOString() });
701
+ }
702
+ };
703
+ var SHAPES = [
704
+ new RegExp(`^projects/[a-z0-9-]+/(archive/)?(${COLLECTIONS.join("|")})/[A-Za-z0-9_.-]+\\.json$`),
705
+ /^cache\/[a-f0-9]+\.json$/,
706
+ /^archive\/projects\/[A-Za-z0-9_.-]+$/
707
+ ];
708
+ function labelOf(v, fallback) {
709
+ const o = v ?? {};
710
+ for (const k of ["title", "question", "text", "front", "term", "name", "file"]) {
711
+ if (typeof o[k] === "string" && o[k]) return o[k].replace(/\s+/g, " ").slice(0, 100);
712
+ }
713
+ return fallback;
714
+ }
715
+ function items(store2) {
716
+ const out = [];
717
+ const home = store2.home;
718
+ const projects = new Map(store2.projects().map((p) => [p.id, p.name]));
719
+ const addDir = (dir, group, project) => {
720
+ if (!existsSync2(dir)) return;
721
+ for (const f of readdirSync2(dir)) {
722
+ if (!f.endsWith(".json")) continue;
723
+ const p = join4(dir, f);
724
+ const st = statSync(p);
725
+ const rel = p.slice(home.length + 1).split(sep).join("/");
726
+ out.push({ id: rel, group, project, label: labelOf(store2.read(p, null), f), bytes: st.size, at: st.mtime.toISOString() });
727
+ }
728
+ };
729
+ const root = join4(home, "projects");
730
+ if (existsSync2(root)) {
731
+ for (const pid of readdirSync2(root)) {
732
+ const name = projects.get(pid) ?? pid;
733
+ for (const c of COLLECTIONS) {
734
+ addDir(join4(root, pid, c), c, name);
735
+ addDir(join4(root, pid, "archive", c), `archived ${c}`, name);
736
+ }
737
+ }
738
+ }
739
+ addDir(join4(home, "cache"), "reply cache");
740
+ const forgotten = join4(home, "archive", "projects");
741
+ if (existsSync2(forgotten)) {
742
+ for (const f of readdirSync2(forgotten)) {
743
+ const p = join4(forgotten, f);
744
+ const st = statSync(p);
745
+ out.push({ id: `archive/projects/${f}`, group: "forgotten projects", label: f, bytes: walk(p).bytes, at: st.mtime.toISOString() });
746
+ }
747
+ }
748
+ return out.sort((a, b) => b.at.localeCompare(a.at));
749
+ }
750
+ function remove(store2, ids) {
751
+ let removed = 0;
752
+ let freedBytes = 0;
753
+ for (const id of ids) {
754
+ if (id.includes("..") || !SHAPES.some((r) => r.test(id))) continue;
755
+ const p = join4(store2.home, ...id.split("/"));
756
+ if (!existsSync2(p)) continue;
757
+ const st = statSync(p);
758
+ freedBytes += st.isDirectory() ? walk(p).bytes : st.size;
759
+ rmSync(p, { recursive: st.isDirectory(), force: true });
760
+ removed++;
761
+ }
762
+ return { removed, freedBytes };
763
+ }
764
+
765
+ // src/core/workspace.ts
766
+ import { execFileSync as execFileSync2 } from "node:child_process";
767
+ import { createHash as createHash3 } from "node:crypto";
768
+ import { existsSync as existsSync4, readFileSync as readFileSync4, readdirSync as readdirSync3, statSync as statSync3 } from "node:fs";
769
+ import { dirname as dirname2, extname as extname2, join as join6, relative as relative2, resolve as resolve3, sep as sep2, basename as basename2 } from "node:path";
770
+
771
+ // src/core/graph.ts
772
+ import { existsSync as existsSync3, readFileSync as readFileSync3, statSync as statSync2 } from "node:fs";
773
+ import { dirname, extname, join as join5, relative, resolve as resolve2 } from "node:path";
774
+ var JS_EXT = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
775
+ var JS_RE = /(?:^|\n)\s*(?:import|export)\s[^'"`;]*?from\s*['"]([^'"]+)['"]|(?:^|[^\w.])(?:require|import)\(\s*['"]([^'"]+)['"]\s*\)|(?:^|\n)\s*import\s*['"]([^'"]+)['"]/g;
776
+ var PY_FROM = /^\s*from\s+(\.*)([\w.]*)\s+import\s+([\w*, ()]+)/gm;
777
+ var PY_IMPORT = /^\s*import\s+([\w.]+(?:\s*,\s*[\w.]+)*)/gm;
778
+ var MAX_FILE = 3e5;
779
+ var MAX_FILES = 6e3;
780
+ function isFile(p) {
781
+ try {
782
+ return statSync2(p).isFile();
783
+ } catch {
784
+ return false;
785
+ }
786
+ }
787
+ function readAliases(root, files) {
788
+ const configs = files.filter((f) => {
789
+ const rel = f.slice(root.length + 1);
790
+ return /(^|\/)tsconfig[\w.-]*\.json$/.test(rel) && rel.split("/").length <= 2;
791
+ });
792
+ const out = [];
793
+ for (const cfg of configs) {
794
+ try {
795
+ const text2 = readFileSync3(cfg, "utf8").replace(/\/\*[\s\S]*?\*\/|(^|[^:"])\/\/.*$/gm, "$1").replace(/,(\s*[}\]])/g, "$1");
796
+ const co = JSON.parse(text2).compilerOptions ?? {};
797
+ const base = resolve2(dirname(cfg), co.baseUrl ?? ".");
798
+ for (const [k, v] of Object.entries(co.paths ?? {})) {
799
+ out.push({ prefix: k.replace(/\*$/, ""), targets: v.map((t) => resolve2(base, t.replace(/\*$/, ""))), dir: dirname(cfg) });
800
+ }
801
+ } catch {
802
+ }
803
+ }
804
+ return out.sort((a, b) => b.prefix.length - a.prefix.length);
805
+ }
806
+ function resolveJs(from, spec, known, aliases = []) {
807
+ if (!spec.startsWith(".")) {
808
+ const matching = aliases.filter((x) => x.prefix && spec.startsWith(x.prefix)).sort((a, b) => Number(from.startsWith(b.dir + "/")) * b.dir.length - Number(from.startsWith(a.dir + "/")) * a.dir.length);
809
+ for (const a of matching) {
810
+ for (const t of a.targets) {
811
+ const hit = resolveJs(from, `./${relativeTo(from, join5(t, spec.slice(a.prefix.length)))}`, known);
812
+ if (hit) return hit;
813
+ }
814
+ }
815
+ return null;
816
+ }
817
+ const base = resolve2(dirname(from), spec);
818
+ const tries = [base];
819
+ const ext = extname(base);
820
+ if (ext === ".js" || ext === ".mjs" || ext === ".jsx") for (const e of [".ts", ".tsx", ".mts"]) tries.push(base.slice(0, -ext.length) + e);
821
+ for (const e of JS_EXT) tries.push(base + e);
822
+ for (const e of JS_EXT) tries.push(join5(base, `index${e}`));
823
+ return tries.find((t) => known.has(t)) ?? null;
824
+ }
825
+ function relativeTo(from, to) {
826
+ return relative(dirname(from), to) || ".";
827
+ }
828
+ function pyRoots(root, files) {
829
+ const roots = /* @__PURE__ */ new Set([root]);
830
+ for (const f of files) {
831
+ const m = /^(.*?\/src)\//.exec(f);
832
+ if (m && f.endsWith(".py")) roots.add(m[1]);
833
+ }
834
+ return [...roots];
835
+ }
836
+ function resolvePyModule(mod, roots, known) {
837
+ const rel = mod.split(".").join("/");
838
+ for (const r of roots) {
839
+ for (const t of [join5(r, `${rel}.py`), join5(r, rel, "__init__.py")]) if (known.has(t)) return t;
840
+ }
841
+ return null;
842
+ }
843
+ function pyImports(from, src, roots, known) {
844
+ const out = [];
845
+ for (const m of src.matchAll(PY_FROM)) {
846
+ const [, dots, mod, names] = m;
847
+ if (dots) {
848
+ let dir = dirname(from);
849
+ for (let i = 1; i < dots.length; i++) dir = dirname(dir);
850
+ const base = mod ? join5(dir, ...mod.split(".")) : dir;
851
+ const hit = [`${base}.py`, join5(base, "__init__.py")].find((t) => known.has(t));
852
+ if (hit) out.push(hit);
853
+ for (const n of names.replace(/[()]/g, "").split(",").map((x) => x.trim().split(/\s+/)[0]).filter(Boolean)) {
854
+ const sib = join5(base, `${n}.py`);
855
+ if (known.has(sib)) out.push(sib);
856
+ }
857
+ } else {
858
+ const hit = resolvePyModule(mod, roots, known);
859
+ if (hit) out.push(hit);
860
+ for (const n of names.replace(/[()]/g, "").split(",").map((x) => x.trim().split(/\s+/)[0]).filter(Boolean)) {
861
+ const sub = resolvePyModule(`${mod}.${n}`, roots, known);
862
+ if (sub) out.push(sub);
863
+ }
864
+ }
865
+ }
866
+ for (const m of src.matchAll(PY_IMPORT)) {
867
+ for (const mod of m[1].split(",").map((x) => x.trim().split(/\s+/)[0])) {
868
+ const hit = resolvePyModule(mod, roots, known);
869
+ if (hit) out.push(hit);
870
+ }
871
+ }
872
+ return out;
873
+ }
874
+ function importGraph(root, files) {
875
+ const list = files.filter((f) => /\.(m?[jt]sx?|cjs|mts|py)$/.test(f)).slice(0, MAX_FILES);
876
+ const aliases = readAliases(root, files);
877
+ const known = new Set(list);
878
+ const roots = pyRoots(root, list);
879
+ const edges = /* @__PURE__ */ new Map();
880
+ for (const f of list) {
881
+ if (!existsSync3(f) || !isFile(f)) continue;
882
+ let src;
883
+ try {
884
+ const buf = readFileSync3(f);
885
+ if (buf.length > MAX_FILE) continue;
886
+ src = buf.toString("utf8");
887
+ } catch {
888
+ continue;
889
+ }
890
+ const to = /* @__PURE__ */ new Set();
891
+ if (f.endsWith(".py")) {
892
+ for (const t of pyImports(f, src, roots, known)) if (t !== f) to.add(t);
893
+ } else {
894
+ for (const m of src.matchAll(JS_RE)) {
895
+ const t = resolveJs(f, m[1] ?? m[2] ?? m[3], known, aliases);
896
+ if (t && t !== f) to.add(t);
897
+ }
898
+ }
899
+ if (to.size) edges.set(f, to);
900
+ }
901
+ const fanIn = /* @__PURE__ */ new Map();
902
+ for (const to of edges.values()) for (const t of to) fanIn.set(t, (fanIn.get(t) ?? 0) + 1);
903
+ const used = /* @__PURE__ */ new Set();
904
+ for (const [f, to] of edges) {
905
+ used.add(f);
906
+ for (const t of to) used.add(t);
907
+ }
908
+ return {
909
+ nodes: [...used].map((path2) => ({ path: path2, fanIn: fanIn.get(path2) ?? 0, fanOut: edges.get(path2)?.size ?? 0 })),
910
+ edges: [...edges].flatMap(([from, to]) => [...to].map((t) => ({ from, to: t })))
911
+ };
912
+ }
913
+
914
+ // src/core/workspace.ts
915
+ var SKIP_DIRS = /* @__PURE__ */ new Set([
916
+ ".git",
917
+ "node_modules",
918
+ ".venv",
919
+ "venv",
920
+ "__pycache__",
921
+ ".pytest_cache",
922
+ ".mypy_cache",
923
+ ".ruff_cache",
924
+ "dist",
925
+ "build",
926
+ "out",
927
+ "target",
928
+ ".next",
929
+ ".turbo",
930
+ ".cache",
931
+ "cdk.out",
932
+ ".idea",
933
+ ".tox",
934
+ "coverage"
935
+ ]);
936
+ var MAX_FILE_BYTES = 4e5;
937
+ var EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
938
+ var REPO_SCAN_DEPTH = 4;
939
+ var MAX_TREE_FILES = 2e4;
940
+ var LANGS = {
941
+ ".ts": "typescript",
942
+ ".tsx": "tsx",
943
+ ".js": "javascript",
944
+ ".jsx": "jsx",
945
+ ".mjs": "javascript",
946
+ ".cjs": "javascript",
947
+ ".py": "python",
948
+ ".go": "go",
949
+ ".rs": "rust",
950
+ ".java": "java",
951
+ ".kt": "kotlin",
952
+ ".rb": "ruby",
953
+ ".php": "php",
954
+ ".cs": "csharp",
955
+ ".c": "c",
956
+ ".h": "c",
957
+ ".cpp": "cpp",
958
+ ".hpp": "cpp",
959
+ ".swift": "swift",
960
+ ".scala": "scala",
961
+ ".sh": "bash",
962
+ ".zsh": "bash",
963
+ ".bash": "bash",
964
+ ".sql": "sql",
965
+ ".json": "json",
966
+ ".yaml": "yaml",
967
+ ".yml": "yaml",
968
+ ".toml": "toml",
969
+ ".md": "markdown",
970
+ ".html": "html",
971
+ ".css": "css",
972
+ ".scss": "scss",
973
+ ".vue": "vue",
974
+ ".svelte": "svelte",
975
+ ".tf": "hcl",
976
+ ".hcl": "hcl",
977
+ ".xml": "xml",
978
+ ".ini": "ini",
979
+ ".dockerfile": "docker",
980
+ ".lua": "lua",
981
+ ".r": "r",
982
+ ".dart": "dart",
983
+ ".ex": "elixir",
984
+ ".exs": "elixir",
985
+ ".jinja": "jinja",
986
+ ".j2": "jinja"
987
+ };
988
+ function langOf(path2) {
989
+ const name = basename2(path2).toLowerCase();
990
+ if (name === "dockerfile") return "docker";
991
+ if (name === "makefile") return "make";
992
+ return LANGS[extname2(name)] ?? "text";
993
+ }
994
+ function hashText(text2) {
995
+ return createHash3("sha1").update(text2).digest("hex").slice(0, 12);
996
+ }
997
+ function git(cwd, args) {
998
+ return execFileSync2("git", args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
999
+ }
1000
+ function isGitRepo(dir) {
1001
+ try {
1002
+ return git(dir, ["rev-parse", "--is-inside-work-tree"]).trim() === "true";
1003
+ } catch {
1004
+ return false;
1005
+ }
1006
+ }
1007
+ var Workspace = class {
1008
+ constructor(project) {
1009
+ this.project = project;
1010
+ }
1011
+ get roots() {
1012
+ return [this.project.root, ...this.project.contexts];
1013
+ }
1014
+ /** Throw unless `path` is inside one of the project's folders. Returns the absolute path. */
1015
+ guard(path2) {
1016
+ const abs = resolve3(path2);
1017
+ const ok = this.roots.some((r) => abs === r || abs.startsWith(r.endsWith(sep2) ? r : r + sep2));
1018
+ if (!ok) throw new Error(`outside the project: ${path2}`);
1019
+ return abs;
1020
+ }
1021
+ rel(path2) {
1022
+ const abs = resolve3(path2);
1023
+ for (const r of this.roots) {
1024
+ if (abs.startsWith(r + sep2)) {
1025
+ const inner = relative2(r, abs);
1026
+ return r === this.project.root ? inner : `${basename2(r)}/${inner}`;
1027
+ }
1028
+ }
1029
+ return abs;
1030
+ }
1031
+ /** Inverse of rel(): a path the model wrote back to an absolute one, or undefined if unknown. */
1032
+ absFromRel(rel) {
1033
+ if (!rel) return void 0;
1034
+ const clean = rel.replace(/^\.\//, "");
1035
+ const candidates = [join6(this.project.root, clean)];
1036
+ for (const c of this.project.contexts) {
1037
+ const name = basename2(c);
1038
+ if (clean.startsWith(name + "/")) candidates.push(join6(c, clean.slice(name.length + 1)));
1039
+ }
1040
+ if (clean.startsWith("/")) candidates.unshift(clean);
1041
+ for (const c of candidates) {
1042
+ try {
1043
+ if (statSync3(this.guard(c)).isFile()) return c;
1044
+ } catch {
1045
+ }
1046
+ }
1047
+ return void 0;
1048
+ }
1049
+ // ---- tree ------------------------------------------------------------
1050
+ tree() {
1051
+ return this.roots.filter((r) => existsSync4(r)).map((r) => ({
1052
+ name: r === this.project.root ? this.project.name : basename2(r),
1053
+ path: r,
1054
+ kind: "dir",
1055
+ children: buildTree(r, listFiles(r))
1056
+ }));
1057
+ }
1058
+ /** Every file in the main folder, absolute paths. */
1059
+ files() {
1060
+ const r = this.project.root;
1061
+ return existsSync4(r) ? listFiles(r).map((f) => join6(r, f)) : [];
1062
+ }
1063
+ graph() {
1064
+ return importGraph(this.project.root, this.files());
1065
+ }
1066
+ /**
1067
+ * The file's recent git history with patches, newest first, cut to `maxBytes`.
1068
+ * Throws when the file is not in a git repo.
1069
+ */
1070
+ history(path2, maxBytes) {
1071
+ const abs = this.guard(path2);
1072
+ const dir = dirname2(abs);
1073
+ if (!isGitRepo(dir)) throw new Error("the history lens needs a file inside a git repo");
1074
+ const out = git(dir, ["log", "--follow", "-n", "15", "-p", "--format=--- commit %h, %an, %cr%n%s%n%n%b", "--", abs]);
1075
+ if (!out.trim()) throw new Error("this file has no commits yet");
1076
+ return out.length > maxBytes ? `${out.slice(0, maxBytes)}
1077
+ (older history cut)` : out;
1078
+ }
1079
+ // ---- files -----------------------------------------------------------
1080
+ read(path2) {
1081
+ const abs = this.guard(path2);
1082
+ const st = statSync3(abs);
1083
+ if (!st.isFile()) throw new Error("not a file");
1084
+ const buf = readFileSync4(abs);
1085
+ const truncated = buf.length > MAX_FILE_BYTES;
1086
+ const slice = truncated ? buf.subarray(0, MAX_FILE_BYTES) : buf;
1087
+ const text2 = slice.includes(0) ? "(binary file)" : slice.toString("utf8");
1088
+ return { path: abs, rel: this.rel(abs), lang: langOf(abs), text: text2, hash: hashText(text2), truncated };
1089
+ }
1090
+ hash(path2) {
1091
+ try {
1092
+ return this.read(path2).hash;
1093
+ } catch {
1094
+ return null;
1095
+ }
1096
+ }
1097
+ /**
1098
+ * Files with line numbers, for a prompt. Stops at `maxBytes` and says which files were cut,
1099
+ * so the model knows it did not see everything.
1100
+ */
1101
+ bundle(paths, maxBytes) {
1102
+ const parts = [];
1103
+ let used = 0;
1104
+ const cut = [];
1105
+ for (const p of paths) {
1106
+ let f;
1107
+ try {
1108
+ f = this.read(p);
1109
+ } catch {
1110
+ continue;
1111
+ }
1112
+ const numbered = f.text.split("\n").map((l, i) => `${String(i + 1).padStart(4)}| ${l}`).join("\n");
1113
+ const block = `=== FILE ${f.rel} (${f.lang}) ===
1114
+ ${numbered}
1115
+ `;
1116
+ if (used + block.length > maxBytes) {
1117
+ cut.push(f.rel);
1118
+ continue;
1119
+ }
1120
+ used += block.length;
1121
+ parts.push(block);
1122
+ }
1123
+ if (cut.length) parts.push(`(Not included, too large for one prompt: ${cut.join(", ")})`);
1124
+ return parts.join("\n");
1125
+ }
1126
+ // ---- git -------------------------------------------------------------
1127
+ /**
1128
+ * Git repos in the project: a folder that is itself in a repo counts as one; otherwise
1129
+ * sub-folders are scanned (up to REPO_SCAN_DEPTH levels, not inside a found repo), so a
1130
+ * workspace folder that holds many repos lists each of them.
1131
+ */
1132
+ repos() {
1133
+ const found = [];
1134
+ const scan = (dir, depth) => {
1135
+ let entries;
1136
+ try {
1137
+ entries = readdirSync3(dir, { withFileTypes: true });
1138
+ } catch {
1139
+ return;
1140
+ }
1141
+ for (const e of entries) {
1142
+ if (!e.isDirectory() || e.name.startsWith(".") || SKIP_DIRS.has(e.name)) continue;
1143
+ const p = join6(dir, e.name);
1144
+ if (existsSync4(join6(p, ".git"))) found.push(p);
1145
+ else if (depth < REPO_SCAN_DEPTH) scan(p, depth + 1);
1146
+ }
1147
+ };
1148
+ for (const r of this.roots) {
1149
+ if (!existsSync4(r)) continue;
1150
+ if (isGitRepo(r)) found.push(r);
1151
+ else scan(r, 1);
1152
+ }
1153
+ return found.map((path2) => {
1154
+ let branch = "";
1155
+ try {
1156
+ branch = git(path2, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
1157
+ } catch {
1158
+ }
1159
+ const name = path2 === this.project.root ? this.project.name : this.project.contexts.includes(path2) ? basename2(path2) : this.rel(path2);
1160
+ return { path: path2, name, branch };
1161
+ });
1162
+ }
1163
+ /** The repo folder for a request: the given one (checked), or the project root. */
1164
+ repo(path2) {
1165
+ const dir = path2 ? this.guard(path2) : this.project.root;
1166
+ return isGitRepo(dir) ? dir : null;
1167
+ }
1168
+ refs(repoPath) {
1169
+ const root = this.repo(repoPath);
1170
+ if (!root) return { branches: [], commits: [], current: "" };
1171
+ let current = "";
1172
+ try {
1173
+ current = git(root, ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
1174
+ } catch {
1175
+ }
1176
+ let branches = [];
1177
+ let commits = [];
1178
+ try {
1179
+ branches = git(root, ["for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes"]).split("\n").filter(Boolean).filter((b) => !b.endsWith("/HEAD"));
1180
+ commits = git(root, ["log", "-30", "--format=%h%x09%s%x09%cr"]).split("\n").filter(Boolean).map((l) => {
1181
+ const [sha, subject, when] = l.split(" ");
1182
+ return { sha, subject, when };
1183
+ });
1184
+ } catch {
1185
+ }
1186
+ return { branches, commits, current };
1187
+ }
1188
+ /**
1189
+ * A ref git can diff. HEAD in a repo with no commits becomes the empty tree, so a new repo
1190
+ * shows all its files as added.
1191
+ */
1192
+ resolveRef(root, ref) {
1193
+ if (!/^[\w./~^@{}-]+$/.test(ref) || ref.startsWith("-")) throw new Error(`bad ref: ${ref}`);
1194
+ try {
1195
+ git(root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
1196
+ return ref;
1197
+ } catch {
1198
+ if (ref === "HEAD") return EMPTY_TREE;
1199
+ throw new Error(`bad ref: ${ref} is not a commit`);
1200
+ }
1201
+ }
1202
+ /**
1203
+ * Changed files between `base` and `head` (empty head = working tree, untracked files included).
1204
+ * `--relative` keeps paths relative to the folder, also when it sits inside a bigger repo.
1205
+ */
1206
+ diff(spec) {
1207
+ const root = this.repo(spec.repo);
1208
+ if (!root) return [];
1209
+ const range = (spec.head ? [spec.base, spec.head] : [spec.base]).map((r) => this.resolveRef(root, r));
1210
+ const files = [];
1211
+ const numstat = git(root, ["diff", "--relative", "--numstat", "-M", ...range, "--"]).split("\n").filter(Boolean);
1212
+ const status = /* @__PURE__ */ new Map();
1213
+ for (const l of git(root, ["diff", "--relative", "--name-status", "-M", ...range, "--"]).split("\n").filter(Boolean)) {
1214
+ const parts = l.split(" ");
1215
+ status.set(parts[parts.length - 1], parts[0][0]);
1216
+ }
1217
+ for (const l of numstat) {
1218
+ const [add, del, ...rest] = l.split(" ");
1219
+ let path2 = rest.join(" ");
1220
+ if (path2.includes(" => ")) path2 = path2.replace(/\{[^}]* => ([^}]*)\}/, "$1").replace(/^.* => /, "");
1221
+ const s = status.get(path2) ?? "M";
1222
+ const patch = git(root, ["diff", "--relative", "-M", ...range, "--", path2]);
1223
+ files.push({
1224
+ path: join6(root, path2),
1225
+ status: s === "A" ? "added" : s === "D" ? "deleted" : s === "R" ? "renamed" : "modified",
1226
+ additions: Number(add) || 0,
1227
+ deletions: Number(del) || 0,
1228
+ patch
1229
+ });
1230
+ }
1231
+ if (!spec.head) {
1232
+ const untracked = git(root, ["ls-files", "--others", "--exclude-standard"]).split("\n").filter(Boolean);
1233
+ for (const rel of untracked.slice(0, 200)) {
1234
+ const abs = join6(root, rel);
1235
+ let text2 = "";
1236
+ try {
1237
+ text2 = this.read(abs).text;
1238
+ } catch {
1239
+ continue;
1240
+ }
1241
+ const lines = text2.split("\n");
1242
+ const patch = `--- /dev/null
1243
+ +++ b/${rel}
1244
+ @@ -0,0 +1,${lines.length} @@
1245
+ ${lines.map((l) => "+" + l).join("\n")}`;
1246
+ files.push({ path: abs, status: "added", additions: lines.length, deletions: 0, patch });
1247
+ }
1248
+ }
1249
+ return files;
1250
+ }
1251
+ };
1252
+ function listFiles(root) {
1253
+ if (isGitRepo(root)) {
1254
+ try {
1255
+ const out2 = git(root, ["ls-files", "--cached", "--others", "--exclude-standard"]);
1256
+ return out2.split("\n").filter(Boolean).filter((p) => existsSync4(join6(root, p))).slice(0, MAX_TREE_FILES);
1257
+ } catch {
1258
+ }
1259
+ }
1260
+ const out = [];
1261
+ const walk2 = (dir) => {
1262
+ if (out.length >= MAX_TREE_FILES) return;
1263
+ let entries;
1264
+ try {
1265
+ entries = readdirSync3(dir, { withFileTypes: true });
1266
+ } catch {
1267
+ return;
1268
+ }
1269
+ for (const e of entries) {
1270
+ if (e.name.startsWith(".") && e.name !== ".github") continue;
1271
+ const p = join6(dir, e.name);
1272
+ if (e.isDirectory()) {
1273
+ if (!SKIP_DIRS.has(e.name)) walk2(p);
1274
+ } else if (e.isFile()) out.push(relative2(root, p));
1275
+ }
1276
+ };
1277
+ walk2(root);
1278
+ return out;
1279
+ }
1280
+ function buildTree(root, files) {
1281
+ const top = { name: "", path: root, kind: "dir", children: [] };
1282
+ for (const rel of files) {
1283
+ const parts = rel.split("/");
1284
+ let node = top;
1285
+ parts.forEach((part, i) => {
1286
+ const isFile2 = i === parts.length - 1;
1287
+ node.children ??= [];
1288
+ let child = node.children.find((c) => c.name === part && c.kind === (isFile2 ? "file" : "dir"));
1289
+ if (!child) {
1290
+ child = { name: part, path: join6(node.path, part), kind: isFile2 ? "file" : "dir" };
1291
+ if (!isFile2) child.children = [];
1292
+ node.children.push(child);
1293
+ }
1294
+ node = child;
1295
+ });
1296
+ }
1297
+ const sort = (n) => {
1298
+ n.children?.sort((a, b) => a.kind === b.kind ? a.name.localeCompare(b.name) : a.kind === "dir" ? -1 : 1);
1299
+ n.children?.forEach(sort);
1300
+ };
1301
+ sort(top);
1302
+ return top.children ?? [];
1303
+ }
1304
+ function browse(dir) {
1305
+ const abs = resolve3(dir);
1306
+ const dirs = readdirSync3(abs, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".") && !SKIP_DIRS.has(e.name)).map((e) => e.name).sort((a, b) => a.localeCompare(b));
1307
+ const parent = resolve3(abs, "..");
1308
+ return { path: abs, parent: parent === abs ? null : parent, dirs, isGit: existsSync4(join6(abs, ".git")) };
1309
+ }
1310
+
1311
+ // src/core/insights.ts
1312
+ var DAY2 = 864e5;
1313
+ var scheduler = fsrs();
1314
+ var RATINGS = { again: R.Again, hard: R.Hard, good: R.Good, easy: R.Easy };
1315
+ function toFsrs(s) {
1316
+ return { ...s, due: new Date(s.due), last_review: s.last_review ? new Date(s.last_review) : void 0 };
1317
+ }
1318
+ function fromFsrs(c) {
1319
+ return {
1320
+ due: c.due.toISOString(),
1321
+ stability: c.stability,
1322
+ difficulty: c.difficulty,
1323
+ elapsed_days: c.elapsed_days,
1324
+ scheduled_days: c.scheduled_days,
1325
+ learning_steps: c.learning_steps,
1326
+ reps: c.reps,
1327
+ lapses: c.lapses,
1328
+ state: c.state,
1329
+ last_review: c.last_review?.toISOString()
1330
+ };
1331
+ }
1332
+ function newCardState(now2 = /* @__PURE__ */ new Date()) {
1333
+ return fromFsrs(createEmptyCard(now2));
1334
+ }
1335
+ function previewCard(card, now2 = /* @__PURE__ */ new Date()) {
1336
+ const p = scheduler.repeat(toFsrs(card.fsrs), now2);
1337
+ return {
1338
+ again: p[R.Again].card.due.toISOString(),
1339
+ hard: p[R.Hard].card.due.toISOString(),
1340
+ good: p[R.Good].card.due.toISOString(),
1341
+ easy: p[R.Easy].card.due.toISOString()
1342
+ };
1343
+ }
1344
+ function rateCard(card, rating, now2 = /* @__PURE__ */ new Date()) {
1345
+ const next = scheduler.next(toFsrs(card.fsrs), now2, RATINGS[rating]);
1346
+ card.fsrs = fromFsrs(next.card);
1347
+ card.reviews = [...card.reviews, { at: now2.toISOString(), rating }].slice(-CARD_REVIEW_CAP);
1348
+ return card;
1349
+ }
1350
+ function makeCard(projectId, c) {
1351
+ return {
1352
+ id: newId(),
1353
+ projectId,
1354
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1355
+ kind: c.kind,
1356
+ front: c.front.slice(0, 2e3),
1357
+ back: c.back.slice(0, 4e3),
1358
+ file: c.file,
1359
+ line: c.line,
1360
+ source: c.source,
1361
+ fsrs: newCardState(),
1362
+ reviews: []
1363
+ };
1364
+ }
1365
+ function dueCards(store2, projectId, now2 = /* @__PURE__ */ new Date()) {
1366
+ return store2.list(projectId, "cards").filter((c) => !c.suspended && Date.parse(c.fsrs.due) <= now2.getTime()).sort((a, b) => a.fsrs.due.localeCompare(b.fsrs.due));
1367
+ }
1368
+ var norm = (s) => s.toLowerCase().replace(/[^a-z0-9 ]+/g, " ").replace(/\s+/g, " ").trim();
1369
+ function recordMisconception(store2, projectId, m) {
1370
+ const text2 = m.text.trim().slice(0, 500);
1371
+ if (text2.length < 8) return null;
1372
+ const all = store2.list(projectId, "misconceptions");
1373
+ const same = all.find((x) => !x.resolvedAt && (x.sourceId === m.sourceId || norm(x.text) === norm(text2)));
1374
+ if (same) {
1375
+ same.seen++;
1376
+ same.text = text2;
1377
+ return store2.put(projectId, "misconceptions", same);
1378
+ }
1379
+ return store2.put(projectId, "misconceptions", {
1380
+ id: newId(),
1381
+ projectId,
1382
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1383
+ text: text2,
1384
+ file: m.file,
1385
+ line: m.line,
1386
+ from: m.from,
1387
+ sourceId: m.sourceId,
1388
+ seen: 1
1389
+ });
1390
+ }
1391
+ function resolveMisconception(store2, projectId, pick) {
1392
+ let n = 0;
1393
+ for (const m of store2.list(projectId, "misconceptions")) {
1394
+ if (m.resolvedAt || !pick(m)) continue;
1395
+ m.resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
1396
+ store2.put(projectId, "misconceptions", m);
1397
+ n++;
1398
+ }
1399
+ return n;
1400
+ }
1401
+ function openMisconceptions(store2, projectId) {
1402
+ return store2.list(projectId, "misconceptions").filter((m) => !m.resolvedAt).sort((a, b) => b.seen - a.seen || b.createdAt.localeCompare(a.createdAt));
1403
+ }
1404
+ function signals(store2, project, passMark) {
1405
+ const out = [];
1406
+ const pid = project.id;
1407
+ for (const l of store2.list(pid, "lenses")) for (const f of l.files) out.push({ file: f, at: l.createdAt, pass: false, recall: false, kind: "lens" });
1408
+ for (const q of store2.list(pid, "quizzes")) {
1409
+ for (const f of q.files) {
1410
+ if (!q.attempts.length) out.push({ file: f, at: q.createdAt, pass: false, recall: false, kind: "quiz" });
1411
+ for (const a of q.attempts) out.push({ file: f, at: a.at, pass: a.score >= passMark, recall: true, hash: q.fileHashes[f], kind: "quiz", score: a.score });
1412
+ }
1413
+ }
1414
+ for (const p of store2.list(pid, "practice")) {
1415
+ const allRight = !!p.review && p.review.holes.every((h) => h.verdict === "right");
1416
+ out.push({ file: p.file, at: p.review?.at ?? p.createdAt, pass: allRight, recall: !!p.review, hash: hashText(p.original), kind: "practice" });
1417
+ }
1418
+ for (const t of store2.list(pid, "teachbacks")) {
1419
+ if (!t.grade) continue;
1420
+ for (const f of t.files) out.push({ file: f, at: t.createdAt, pass: t.grade.score >= 4, recall: true, hash: t.fileHashes[f], kind: "other" });
1421
+ }
1422
+ for (const w of store2.list(pid, "walks")) {
1423
+ const answered = w.blocks.filter((b) => b.verdict);
1424
+ if (!answered.length) {
1425
+ out.push({ file: w.file, at: w.createdAt, pass: false, recall: false, kind: "other" });
1426
+ continue;
1427
+ }
1428
+ const pts = answered.reduce((s, b) => s + (b.verdict === "right" ? 1 : b.verdict === "partial" ? 0.5 : 0), 0);
1429
+ const done = answered.length === w.blocks.length;
1430
+ out.push({ file: w.file, at: w.createdAt, pass: done && pts / w.blocks.length >= passMark, recall: true, hash: w.fileHash, kind: "other" });
1431
+ }
1432
+ for (const h of store2.list(pid, "hunts")) {
1433
+ if (!h.grade) out.push({ file: h.file, at: h.createdAt, pass: false, recall: false, kind: "other" });
1434
+ else out.push({ file: h.file, at: h.grade.at, pass: h.grade.score >= passMark, recall: true, hash: h.fileHash, kind: "other" });
1435
+ }
1436
+ for (const c of store2.list(pid, "cards")) {
1437
+ const last = c.reviews[c.reviews.length - 1];
1438
+ if (c.file && last) out.push({ file: c.file, at: last.at, pass: false, recall: true, kind: "card" });
1439
+ }
1440
+ return out;
1441
+ }
1442
+ function progress(store2, project, now2 = Date.now()) {
1443
+ const s = store2.settings();
1444
+ const ws = new Workspace(project);
1445
+ const map = /* @__PURE__ */ new Map();
1446
+ const hashes = /* @__PURE__ */ new Map();
1447
+ const hashOf = (f) => {
1448
+ if (!hashes.has(f)) hashes.set(f, ws.hash(f));
1449
+ return hashes.get(f);
1450
+ };
1451
+ for (const sig of signals(store2, project, s["quiz.passMark"])) {
1452
+ let fp = map.get(sig.file);
1453
+ if (!fp) {
1454
+ fp = { path: sig.file, level: "new", lenses: 0, quizzes: 0, bestScore: null, practices: 0, other: 0, lastRetrieval: null, fresh: false, passed: false };
1455
+ map.set(sig.file, fp);
1456
+ }
1457
+ if (sig.kind === "lens") fp.lenses++;
1458
+ if (sig.kind === "practice") fp.practices++;
1459
+ if (sig.kind === "other") fp.other++;
1460
+ if (sig.kind === "quiz" && sig.score !== void 0) fp.bestScore = Math.max(fp.bestScore ?? 0, sig.score);
1461
+ if (sig.recall && (!fp.lastRetrieval || sig.at > fp.lastRetrieval)) fp.lastRetrieval = sig.at;
1462
+ if (sig.pass) {
1463
+ fp.passed = true;
1464
+ if (sig.hash && sig.hash === hashOf(sig.file)) fp.fresh = true;
1465
+ }
1466
+ }
1467
+ for (const q of store2.list(project.id, "quizzes")) for (const f of q.files) {
1468
+ const fp = map.get(f);
1469
+ if (fp) fp.quizzes++;
1470
+ }
1471
+ const fadeMs = s["progress.fadeDays"] * DAY2;
1472
+ const out = [];
1473
+ for (const { fresh, passed, ...fp } of map.values()) {
1474
+ let level = "seen";
1475
+ if (fresh) level = fadeMs > 0 && fp.lastRetrieval && now2 - Date.parse(fp.lastRetrieval) > fadeMs ? "fading" : "understood";
1476
+ else if (passed) level = "stale";
1477
+ out.push({ ...fp, level });
1478
+ }
1479
+ return out.sort((a, b) => a.path.localeCompare(b.path));
1480
+ }
1481
+ function pathDone(store2, path2) {
1482
+ const pid = path2.projectId;
1483
+ const since = path2.createdAt;
1484
+ const passMark = store2.settings()["quiz.passMark"];
1485
+ const covers = (have, want) => want.every((f) => have.includes(f));
1486
+ const lenses = store2.list(pid, "lenses").filter((l) => l.createdAt >= since);
1487
+ const quizzes = store2.list(pid, "quizzes").filter((q) => q.createdAt >= since);
1488
+ const walks = store2.list(pid, "walks").filter((w) => w.createdAt >= since);
1489
+ const teach = store2.list(pid, "teachbacks").filter((t) => t.createdAt >= since);
1490
+ return path2.stops.map((st) => {
1491
+ switch (st.activity) {
1492
+ case "lens":
1493
+ return lenses.some((l) => covers(l.files, st.files));
1494
+ case "walk":
1495
+ return walks.some((w) => st.files.includes(w.file) && w.blocks.every((b) => b.verdict));
1496
+ case "quiz":
1497
+ return quizzes.some((q) => covers(q.files, st.files) && q.attempts.some((a) => a.score >= passMark));
1498
+ case "teachback":
1499
+ return teach.some((t) => covers(t.files, st.files) && (t.grade?.score ?? 0) >= 3);
1500
+ default:
1501
+ return false;
1502
+ }
1503
+ });
1504
+ }
1505
+ function glossary(store2, projectId) {
1506
+ const map = /* @__PURE__ */ new Map();
1507
+ for (const l of store2.list(projectId, "lenses")) {
1508
+ for (const c of l.concepts) {
1509
+ const key = norm(c.term);
1510
+ if (!key) continue;
1511
+ let t = map.get(key);
1512
+ if (!t) map.set(key, t = { term: c.term, explains: [], files: [] });
1513
+ if (!t.explains.some((e) => norm(e.text) === norm(c.explain))) t.explains.push({ text: c.explain, lensId: l.id, lensTitle: l.title });
1514
+ for (const f of l.files) if (!t.files.includes(f)) t.files.push(f);
1515
+ }
1516
+ }
1517
+ return [...map.values()].sort((a, b) => a.term.localeCompare(b.term, void 0, { sensitivity: "base" }));
1518
+ }
1519
+ var dayKey = (iso) => {
1520
+ const d = new Date(iso);
1521
+ return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
1522
+ };
1523
+ function streak(store2, projectId, now2 = Date.now()) {
1524
+ const days = /* @__PURE__ */ new Set();
1525
+ const add = (iso) => iso && days.add(dayKey(iso));
1526
+ for (const l of store2.list(projectId, "lenses")) add(l.createdAt);
1527
+ for (const q of store2.list(projectId, "quizzes")) q.attempts.forEach((a) => add(a.at));
1528
+ for (const c of store2.list(projectId, "cards")) c.reviews.forEach((r) => add(r.at));
1529
+ for (const t2 of store2.list(projectId, "teachbacks")) add(t2.createdAt);
1530
+ for (const w of store2.list(projectId, "walks")) add(w.createdAt);
1531
+ for (const h of store2.list(projectId, "hunts")) add(h.grade?.at);
1532
+ for (const p of store2.list(projectId, "practice")) add(p.review?.at);
1533
+ let n = 0;
1534
+ let t = now2;
1535
+ if (!days.has(dayKey(t))) t -= DAY2;
1536
+ while (days.has(dayKey(t))) {
1537
+ n++;
1538
+ t -= DAY2;
1539
+ }
1540
+ return n;
1541
+ }
1542
+ function today(store2, project, hubs, now2 = /* @__PURE__ */ new Date()) {
1543
+ const cards = store2.list(project.id, "cards").filter((c) => !c.suspended);
1544
+ const due = cards.filter((c) => Date.parse(c.fsrs.due) <= now2.getTime());
1545
+ const weak = openMisconceptions(store2, project.id).slice(0, 5);
1546
+ const prog = progress(store2, project, now2.getTime());
1547
+ const attention = prog.filter((p) => p.level === "stale" || p.level === "fading").sort((a, b) => (a.lastRetrieval ?? "").localeCompare(b.lastRetrieval ?? "")).slice(0, 6);
1548
+ let nextStop = null;
1549
+ const path2 = store2.list(project.id, "paths")[0];
1550
+ if (path2) {
1551
+ const done = pathDone(store2, path2);
1552
+ const i = done.findIndex((d) => !d);
1553
+ if (i >= 0) nextStop = { pathId: path2.id, title: path2.title, index: i, stop: path2.stops[i] };
1554
+ }
1555
+ let suggestion = null;
1556
+ if (due.length) suggestion = { text: `Review ${due.length} card${due.length === 1 ? "" : "s"} (about ${Math.max(1, Math.round(due.length / 3))} min).`, files: [], activity: "review" };
1557
+ else if (weak.length) suggestion = { text: "Quiz your weak spots: questions aimed at what you got wrong before.", files: [], activity: "weak" };
1558
+ else if (attention.length) suggestion = { text: `${attention[0].level === "stale" ? "The code changed since you passed" : "It has been a while since you recalled"} ${new Workspace(project).rel(attention[0].path)}. Quiz it again.`, files: [attention[0].path], activity: "quiz" };
1559
+ else if (nextStop) suggestion = { text: `Next stop on "${nextStop.title}": ${nextStop.stop.title}.`, files: nextStop.stop.files, activity: nextStop.stop.activity };
1560
+ else if (!prog.length) {
1561
+ const start = hubs()[0];
1562
+ if (start) suggestion = { text: `Start with the file most of the code depends on: ${new Workspace(project).rel(start)}. Read a lens, then quiz it.`, files: [start], activity: "lens" };
1563
+ }
1564
+ return {
1565
+ due: due.length,
1566
+ newCards: cards.filter((c) => c.fsrs.reps === 0).length,
1567
+ weak,
1568
+ attention,
1569
+ nextStop,
1570
+ streak: streak(store2, project.id, now2.getTime()),
1571
+ suggestion
1572
+ };
1573
+ }
1574
+
1575
+ // src/core/jobs.ts
1576
+ import { join as join7 } from "node:path";
1577
+ var JOB_CAP = 100;
1578
+ var Jobs = class {
1579
+ constructor(store2) {
1580
+ this.store = store2;
1581
+ if (!store2) return;
1582
+ this.file = join7(store2.home, "jobs.json");
1583
+ for (const j of store2.read(this.file, [])) {
1584
+ if (j.status === "running") Object.assign(j, { status: "failed", error: "learnforge stopped while this ran", endedAt: j.startedAt });
1585
+ this.jobs.set(j.id, j);
1586
+ }
1587
+ }
1588
+ jobs = /* @__PURE__ */ new Map();
1589
+ file;
1590
+ save() {
1591
+ while (this.jobs.size > JOB_CAP) {
1592
+ const oldest = [...this.jobs.values()].find((j) => j.status !== "running");
1593
+ if (!oldest) break;
1594
+ this.jobs.delete(oldest.id);
1595
+ }
1596
+ if (this.store && this.file) this.store.write(this.file, [...this.jobs.values()]);
1597
+ }
1598
+ start(kind, projectId, work) {
1599
+ const job = { id: newId(), kind, projectId, status: "running", startedAt: (/* @__PURE__ */ new Date()).toISOString() };
1600
+ this.jobs.set(job.id, job);
1601
+ this.save();
1602
+ work().then((r) => Object.assign(job, { status: "done", resultId: r.id })).catch((e) => Object.assign(job, { status: "failed", error: e instanceof Error ? e.message : String(e) })).finally(() => {
1603
+ job.endedAt = (/* @__PURE__ */ new Date()).toISOString();
1604
+ this.save();
1605
+ });
1606
+ return job;
1607
+ }
1608
+ get(id) {
1609
+ return this.jobs.get(id);
1610
+ }
1611
+ running(projectId) {
1612
+ return [...this.jobs.values()].filter((j) => j.projectId === projectId && j.status === "running");
1613
+ }
1614
+ /** Newest first. */
1615
+ history(limit = 50) {
1616
+ return [...this.jobs.values()].reverse().slice(0, limit);
1617
+ }
1618
+ };
1619
+
1620
+ // src/core/prompts.ts
1621
+ var LENS_KINDS = [
1622
+ {
1623
+ id: "explain",
1624
+ label: "Explain",
1625
+ question: "What does this code do, and what do I need to know to read it?",
1626
+ blurb: "The tour: purpose, main pieces, how they connect."
1627
+ },
1628
+ {
1629
+ id: "flow",
1630
+ label: "Data flow",
1631
+ question: "How does data move through this code, from input to output?",
1632
+ blurb: "Follow one value from where it enters to where it leaves."
1633
+ },
1634
+ {
1635
+ id: "state",
1636
+ label: "State over time",
1637
+ question: "What state does this code keep, and how does it change step by step?",
1638
+ blurb: "What is stored, who changes it, in which order."
1639
+ },
1640
+ {
1641
+ id: "failure",
1642
+ label: "Failure modes",
1643
+ question: "How can this code fail, and what happens when it does?",
1644
+ blurb: "Bad input, timeouts, partial writes, retries."
1645
+ },
1646
+ {
1647
+ id: "contracts",
1648
+ label: "Contracts",
1649
+ question: "What does this code promise its callers, and what does it expect from what it calls?",
1650
+ blurb: "Inputs, outputs, invariants, the edges between parts."
1651
+ },
1652
+ {
1653
+ id: "concepts",
1654
+ label: "Concepts",
1655
+ question: "Which ideas, APIs or patterns must I know to understand this code?",
1656
+ blurb: "The vocabulary first, with the line where each one shows up."
1657
+ },
1658
+ {
1659
+ id: "change",
1660
+ label: "The change",
1661
+ question: "What changed, why might it have changed, and what could it break?",
1662
+ blurb: "Best with a diff: intent, risk, what to check."
1663
+ },
1664
+ {
1665
+ id: "trace",
1666
+ label: "Trace a run",
1667
+ question: "Step through this code on a small input: which line runs, and what does each variable hold?",
1668
+ blurb: "A debugger without running: line by line, with the state."
1669
+ },
1670
+ {
1671
+ id: "history",
1672
+ label: "History",
1673
+ question: "Why is this code the way it is? Explain it through its git history.",
1674
+ blurb: "What changed over time and why, from the commits."
1675
+ }
1676
+ ];
1677
+ var STYLE = `Write for a capable engineer who is new to this code. Plain, short sentences. Name real
1678
+ files and line numbers from the input (the numbers before "|"). Never invent code that is not in the input.
1679
+ Return ONLY one JSON object inside a single \`\`\`json fence. No text outside the fence.`;
1680
+ var DEPTH = {
1681
+ overview: {
1682
+ steps: "3-4",
1683
+ words: 60,
1684
+ concepts: "2-4",
1685
+ gotchas: "0-3",
1686
+ note: "The reader wants the big picture in 3 minutes. Skip details that do not change the mental model."
1687
+ },
1688
+ standard: {
1689
+ steps: "5-8",
1690
+ words: 120,
1691
+ concepts: "3-6",
1692
+ gotchas: "2-5",
1693
+ note: "The reader wants to be able to change this code safely after reading. Give one concrete example with real values in the steps that carry logic."
1694
+ },
1695
+ deep: {
1696
+ steps: "9-14",
1697
+ words: 260,
1698
+ concepts: "5-9",
1699
+ gotchas: "5-8",
1700
+ note: `The reader will own this code and debug it at 2am. This is a long, thorough lesson, not a summary:
1701
+ - Every step is 2-4 paragraphs: (1) what it does and why it exists, (2) a worked example with concrete values
1702
+ from a small input ("if \`rows\` is [3, 0], then ..."), (3) the edge cases, ordering rules or failure path that
1703
+ touch this part, (4) why it was built this way instead of the obvious way, and what a change would break.
1704
+ - Cover EVERY branch, early return, try/except, fallback and default in the files, not only the happy path. If the
1705
+ code has an "if this fails, do that" path, it gets its own step.
1706
+ - Every step has a "pitfall" (a real mistake or a change that would break it) and a "check" question that needs
1707
+ reasoning, not recall ("what happens if X is empty?"), never "what does line 12 do?".
1708
+ - "intuition" has BOTH a worked example with toy data walked through as numbered steps AND a mermaid diagram.
1709
+ - "gotchas" lists real traps (ordering, mutation, silent fallbacks, error swallowing). "nextSteps" is required
1710
+ (2-3 real files that callers, types or tests live in) so the reader knows where to go after this.
1711
+ - Prefer more steps over longer steps when an idea has two halves. Do not pad: every sentence must teach.`
1712
+ }
1713
+ };
1714
+ function lensSystem0(depth = "standard") {
1715
+ const d = DEPTH[depth];
1716
+ return `TASK: lens
1717
+ You build a "lens": a lesson that teaches code through ONE question, like a senior engineer walking a new
1718
+ teammate through it at a whiteboard. ${d.note}
1719
+
1720
+ How to teach (this matters more than coverage):
1721
+ - Lead with the mental model. "tldr" is the one idea that makes the rest make sense, in one sentence a newcomer
1722
+ can repeat. Answer the question directly; no hedging, no "this file is not X".
1723
+ - Explain WHY, not WHAT. The reader can read the code; tell them what it is for, what would break without it,
1724
+ and why it is done this way rather than the obvious way. Never paraphrase a line ("line 12 sets x to y").
1725
+ - Concrete over abstract: real values, a small example input, "if you change A, B happens".
1726
+ - Define every term at first use, in plain words. Short sentences. No filler ("This is important because...").
1727
+ - Write like a good engineering blog post: second person ("you"), short paragraphs of 2-3 sentences with a
1728
+ blank line between them, a bullet list when there are 3+ parallel items. Never one long block of text.
1729
+ - Every field is markdown. Put identifiers, file names, functions, fields and values in \`backticks\`.
1730
+ Use **bold** at most once per field, for the one phrase that matters.
1731
+ - One idea per step. The step title is that idea as a CLAIM the reader learns ("Layer 1 only sees manifests, so
1732
+ code edits never bust it"), not a label ("Layer 1").
1733
+ - Steps go in the order that builds understanding (entry point and data shapes first), not file order. Each
1734
+ step's "explain" connects to the step before.
1735
+ - Each step points at a SHORT line range (3-25 lines) that shows the idea; the page shows those lines next to
1736
+ your text, so do not repeat the code.
1737
+ - If you have file tools, you may open other files in the repo (callers, the types it uses, its tests) to explain
1738
+ how this code is used. Cite them with real paths. If you cannot see something, leave it out instead of
1739
+ guessing or saying you did not look.
1740
+ Name real files and line numbers from the input (the numbers before "|"). Never invent code.
1741
+ Return ONLY one JSON object inside a single \`\`\`json fence. No text outside the fence.
1742
+
1743
+ JSON shape:
1744
+ {
1745
+ "title": "short title, max 8 words",
1746
+ "tldr": "ONE sentence: the mental model that answers the question",
1747
+ "whyItMatters": "1-2 sentences: what goes wrong for someone who does not know this",
1748
+ "summary": "markdown, at most 3 short sentences: the answer in plain words, expanding the tldr. If there is more to say, it belongs in the steps",
1749
+ "concepts": [{"term": "name", "explain": "1-2 plain sentences", "example": "a tiny concrete example or analogy", "file": "relative/path", "line": 12}], // ${d.concepts} items, only terms the reader needs first
1750
+ "intuition": "markdown: the core idea with toy data walked through as numbered steps, and/or a small mermaid diagram (\`\`\`mermaid fenced, max 12 nodes, flowchart or sequenceDiagram, short sentence-case labels, never ALL CAPS; prefer flowchart TD when it has more than 5 nodes)",
1751
+ "deepDives": [{
1752
+ "title": "the idea of this step as a claim",
1753
+ "file": "relative/path", "startLine": 10, "endLine": 24, "line": 12, // line = the single key line
1754
+ "body": "markdown, up to ${d.words} words, in short paragraphs: why this part exists and how it works; connect to the previous step",
1755
+ "pitfall": "optional: a mistake people make here, or what breaks if you change it",
1756
+ "check": {"q": "a short question the reader should now be able to answer", "a": "the answer, 1-2 sentences"}
1757
+ }], // ${d.steps} items, in teaching order
1758
+ "gotchas": ["surprising behaviour, edge cases or traps, 1 sentence each, with the line"], // ${d.gotchas} items
1759
+ "nextSteps": [{"file": "relative/path", "why": "what reading it next will teach"}] // 1-3 items, files that exist
1760
+ }`;
1761
+ }
1762
+ var SCREENS = {
1763
+ overview: { size: "about 1.5 screens", steps: "3-4", paras: "1-2 short paragraphs", concepts: "2-4", gotchas: "1-3" },
1764
+ standard: { size: "at least 3 screens", steps: "6-9", paras: "2-3 paragraphs", concepts: "4-6", gotchas: "3-5" },
1765
+ deep: { size: "5 to 6 screens, a long blog post", steps: "11-15", paras: "3-5 paragraphs", concepts: "5-9", gotchas: "5-8" }
1766
+ };
1767
+ var TEACH = `How to teach (this matters more than coverage):
1768
+ - Lead with the mental model. Answer the question directly; no hedging.
1769
+ - Explain WHY, not WHAT. The reader can read the code; tell them what it is for, what would break without it,
1770
+ and why it is done this way rather than the obvious way. Never paraphrase a line ("line 12 sets x to y").
1771
+ - Concrete over abstract: real values, a small example input, "if you change A, B happens".
1772
+ - Define every term at first use, in plain words. Short sentences. No filler.
1773
+ - Write like a good engineering blog post: second person ("you"), short paragraphs of 2-3 sentences with a blank
1774
+ line between them, a bullet list when there are 3+ parallel items. Never one long block of text.
1775
+ - Every field is markdown. Put identifiers, file names, functions, fields and values in \`backticks\`.
1776
+ Use **bold** at most once per field.
1777
+ - Name real files and line numbers from the input (the numbers before "|"). Never invent code.`;
1778
+ function lensPlanSystem(x = {}) {
1779
+ const d = SCREENS[x.depth ?? "standard"];
1780
+ return `TASK: lensPlan
1781
+ You are the PLANNER of a "lens": a lesson that teaches code through ONE question. A second writer model will
1782
+ write the prose from your plan, so the plan decides how good the lesson is. You do not write the prose.
1783
+ Read all the code first. If you have file tools, open callers, the types it uses and its tests so the plan
1784
+ covers how this code is really used.
1785
+
1786
+ Target size of the finished page: ${d.size}. Choose the number of steps for that size (${d.steps} steps).
1787
+ Do not cut short: for a longer page, cover every branch, early return, fallback, error path and default, not only
1788
+ the happy path, and give each its own step. Teach in the order that builds understanding (entry point and data
1789
+ shapes first), not file order. Each step is ONE idea, titled as a CLAIM the reader learns ("Layer 1 only sees
1790
+ manifests, so code edits never bust it"), not a label.
1791
+
1792
+ ${TEACH}
1793
+ ${STYLE}
1794
+
1795
+ JSON shape:
1796
+ {
1797
+ "title": "short title, max 8 words",
1798
+ "tldr": "ONE sentence: the mental model that answers the question",
1799
+ "whyItMatters": "1-2 sentences: what goes wrong for someone who does not know this",
1800
+ "reader": "who this is for and what they can do after reading, 1 sentence",
1801
+ "concepts": [{"term": "name", "note": "what to explain and the line it shows up at", "file": "relative/path", "line": 12}], // ${d.concepts} terms the reader needs first
1802
+ "intuition": {"example": "a toy input with real values to walk through, and the numbered stages it goes through", "diagram": "what a mermaid diagram should show (flowchart TD or sequenceDiagram, max 12 nodes), or empty"},
1803
+ "steps": [{
1804
+ "title": "the idea as a claim",
1805
+ "file": "relative/path", "startLine": 10, "endLine": 24, "line": 12, // a SHORT range (3-25 lines) that shows the idea
1806
+ "covers": ["each concrete fact, branch or rule this step must explain, in order"],
1807
+ "example": "concrete values to use in this step, or empty",
1808
+ "edge": "the edge case, ordering rule or failure path that touches this part, or empty",
1809
+ "why": "why it is built this way and what a change would break",
1810
+ "pitfall": "a real mistake or breaking change to warn about, or empty",
1811
+ "check": "an idea for a question that needs reasoning, not recall"
1812
+ }],
1813
+ "gotchas": ["a trap, 1 sentence, with the line"], // ${d.gotchas}
1814
+ "nextSteps": [{"file": "relative/path", "why": "what reading it next will teach"}] // 2-3 real files
1815
+ }`;
1816
+ }
1817
+ function lensIntroSystem(x = {}) {
1818
+ const d = SCREENS[x.depth ?? "standard"];
1819
+ const extra = [
1820
+ x.sketch ? ` "differences": ["where the learner's take (SKETCH) is wrong, missing something, or right for the wrong reason; 1-2 sentences each, name the line"], // 1-6 items` : "",
1821
+ x.trace ? ` "trace": {"input": "the toy input used, one line", "steps": [{"file": "relative/path", "line": 12, "state": {"var": "value as short text"}, "note": "what happens, 1 sentence"}]}, // 5-40 steps in run order` : ""
1822
+ ].filter(Boolean).join("\n");
1823
+ return `TASK: lensIntro
1824
+ You are the WRITER of the opening of a lens (a lesson on code). A planner already decided what the lesson says;
1825
+ follow its PLAN and write the opening parts well. The steps are written separately, so do not write them.
1826
+ The whole page will be ${d.size}; the opening sets it up, it does not try to say everything.
1827
+
1828
+ ${TEACH}
1829
+ ${STYLE}
1830
+
1831
+ JSON shape:
1832
+ {
1833
+ ${extra ? extra + "\n" : ""} "summary": "markdown, at most 3 short sentences: the answer in plain words, expanding the tldr",
1834
+ "concepts": [{"term": "name", "explain": "1-2 plain sentences", "example": "a tiny concrete example or analogy", "file": "relative/path", "line": 12}], // the planner's terms, ${d.concepts} items
1835
+ "intuition": "markdown: the core idea walked through with the planner's toy input as numbered stages${x.depth === "overview" ? "" : ", AND a mermaid diagram (```mermaid fenced, max 12 nodes, short sentence-case labels, flowchart TD when more than 5 nodes)"}",
1836
+ "gotchas": ["surprising behaviour or trap, 1 sentence each, with the line"], // ${d.gotchas} items, from the plan
1837
+ "nextSteps": [{"file": "relative/path", "why": "what reading it next will teach"}] // the planner's files that exist
1838
+ }`;
1839
+ }
1840
+ function lensStepsSystem(x = {}) {
1841
+ const d = SCREENS[x.depth ?? "standard"];
1842
+ return `TASK: lensSteps
1843
+ You are the WRITER of some steps of a lens (a lesson on code). A planner outlined every step; you write only the
1844
+ steps you are asked for, in full, following the plan for each one: its "covers" list, "example", "edge" and "why".
1845
+ Write each step's "body" as ${d.paras}. Take the space the idea needs; do not compress, and do not pad.
1846
+ Use the example's real values, walk the edge case, and say why it is built this way. Connect each step to the one
1847
+ before it (the earlier titles are given). The page shows the cited code lines next to your text, so do not repeat
1848
+ the code; explain it.
1849
+ "pitfall": one real mistake or breaking change, 1-3 sentences (omit if the plan has none).
1850
+ "check": a short question that needs reasoning (what happens if X is empty? what breaks first if Y?), and "a", the
1851
+ answer in 1-2 sentences.
1852
+
1853
+ ${TEACH}
1854
+ ${STYLE}
1855
+
1856
+ JSON shape:
1857
+ { "steps": [{"index": 3, "body": "markdown", "pitfall": "...", "check": {"q": "...", "a": "..."}}] } // one item per requested step, same "index" as in the plan (0-based)`;
1858
+ }
1859
+ function lensWritePrompt(question, plan, files, x = {}) {
1860
+ const e = x.extras;
1861
+ return `QUESTION: ${question}
1862
+
1863
+ ${e?.sketch ? `SKETCH (the learner's own take, written before the lesson):
1864
+ ${e.sketch}
1865
+
1866
+ ` : ""}${e?.traceInput !== void 0 ? `TRACE INPUT: ${e.traceInput || "(pick a small, typical input that exercises the main path)"}
1867
+
1868
+ ` : ""}PLAN (from the planner; follow it):
1869
+ ${JSON.stringify(plan, null, 1)}
1870
+
1871
+ ${x.steps ? `WRITE STEPS ${x.steps[0]}-${x.steps[1]} (0-based, both included).${x.titles?.length ? `
1872
+ Earlier steps, for continuity:
1873
+ ${x.titles.map((t) => `- ${t}`).join("\n")}` : ""}
1874
+
1875
+ ` : ""}CODE:
1876
+ ${files}`;
1877
+ }
1878
+ function planSystem(writerSystem) {
1879
+ const task = writerSystem.split("JSON shape:")[0].trim();
1880
+ return `TASK: plan
1881
+ You are the PLANNER. A second model will do the task below and follows your outline, so make it specific.
1882
+ Read the code (open related files if you can). Decide what the output must cover and in what order, which files
1883
+ and line ranges matter, the common traps to include, and what to leave out. Do not write the final output.
1884
+
1885
+ THE TASK THE WRITER WILL DO:
1886
+ ${task}
1887
+
1888
+ ${STYLE}
1889
+
1890
+ JSON shape:
1891
+ { "outline": "markdown, 6-20 lines: the plan, with real files and line numbers" }`;
1892
+ }
1893
+ function lensSystem(x = {}) {
1894
+ const extra = [
1895
+ x.sketch ? ` "differences": ["where the learner's take (SKETCH) is wrong, missing something, or right for the wrong reason; 1-2 sentences each, name the line"], // 1-6 items; if the sketch is fully right say so in one item` : "",
1896
+ x.trace ? ` "trace": {"input": "the toy input used, one line", "steps": [{"file": "relative/path", "line": 12, "state": {"var": "value as short text"}, "note": "what happens, 1 sentence"}]} // 5-40 steps in run order; state lists only variables in scope that matter` : ""
1897
+ ].filter(Boolean);
1898
+ const base = lensSystem0(x.depth);
1899
+ if (!extra.length) return base;
1900
+ const end = `"why": "what reading it next will teach"}]`;
1901
+ return base.replace(end, `${end},`).replace(/\n}$/, `
1902
+ ${extra.join(",\n")}
1903
+ }`);
1904
+ }
1905
+ function lensPrompt(question, files, x = {}) {
1906
+ return `QUESTION: ${question}
1907
+
1908
+ ${x.sketch ? `SKETCH (the learner's own take, written before reading your lesson):
1909
+ ${x.sketch}
1910
+
1911
+ ` : ""}${x.traceInput !== void 0 ? `TRACE INPUT: ${x.traceInput || "(pick a small, typical input that exercises the main path)"}
1912
+
1913
+ ` : ""}${x.previous ? `AN EARLIER LESSON ON THIS CODE SAID (the code has changed since; say what is different now):
1914
+ ${x.previous}
1915
+
1916
+ ` : ""}${x.history ? `GIT HISTORY (newest first):
1917
+ ${x.history}
1918
+
1919
+ ` : ""}${x.diff ? `THE CHANGE (unified diff):
1920
+ ${x.diff}
1921
+
1922
+ ` : ""}${x.notes ? `THE LEARNER'S NOTES:
1923
+ ${x.notes}
1924
+
1925
+ ` : ""}CODE:
1926
+ ${files}`;
1927
+ }
1928
+ function quizSystem(n) {
1929
+ return `TASK: quiz
1930
+ You write a short quiz that checks real understanding of the given code, not recall of names.
1931
+ Ask about behaviour, reasons, edge cases and consequences ("what happens when...", "why does... ",
1932
+ "which line breaks first if..."). Each question must be answerable from the code shown, in 1-4 sentences.
1933
+ Do not put the answer in the question. Mix easy and hard.
1934
+ ${STYLE}
1935
+
1936
+ JSON shape:
1937
+ {
1938
+ "title": "short title",
1939
+ "questions": [{"prompt": "the question", "file": "relative/path", "line": 12, "focus": "what it tests, 3-6 words", "targets": "weak spot id, only if WEAK SPOTS are given"}] // exactly ${n} items
1940
+ }`;
1941
+ }
1942
+ function quizPrompt(files, diff, lensSummary, weak) {
1943
+ const w = weak?.length ? `WEAK SPOTS: this learner got these wrong before. Write questions that probe exactly these gaps, in new words
1944
+ and from a new angle (not the old question). Put the weak spot's id in "targets".
1945
+ ${weak.map((m) => `[${m.id}] ${m.text}`).join("\n")}
1946
+
1947
+ ` : "";
1948
+ return `${w}${lensSummary ? `The learner just read this lesson summary:
1949
+ ${lensSummary}
1950
+
1951
+ ` : ""}${diff ? `THE CHANGE (unified diff):
1952
+ ${diff}
1953
+
1954
+ ` : ""}CODE:
1955
+ ${files}`;
1956
+ }
1957
+ function gradeSystem() {
1958
+ return `TASK: grade
1959
+ You grade a learner's free-text answers about code. Be fair and lenient on wording: if the core idea is right,
1960
+ it is "right"; if part is right, "partial"; a wrong conclusion is "wrong". Feedback: 1-3 sentences, name the
1961
+ line that settles it. modelAnswer: the best short answer, 1-3 sentences.
1962
+ misconception (only for partial or wrong): what the learner seems to BELIEVE that is not true, as one line in
1963
+ the second person ("You think X, but Y"). Not the fact they missed; the wrong model in their head. Omit if the
1964
+ answer is empty or just "I don't know".
1965
+ ${STYLE}
1966
+
1967
+ JSON shape:
1968
+ { "grades": [{"qid": "q1", "verdict": "right|partial|wrong", "feedback": "...", "modelAnswer": "...", "misconception": "..."}] }`;
1969
+ }
1970
+ function gradePrompt(items2, files) {
1971
+ const qa = items2.map((i) => `[${i.qid}] Q: ${i.question}
1972
+ A: ${i.answer || "(no answer)"}`).join("\n\n");
1973
+ return `ANSWERS TO GRADE:
1974
+ ${qa}
1975
+
1976
+ CODE:
1977
+ ${files}`;
1978
+ }
1979
+ var LEVELS = {
1980
+ 1: "1 hole: one short, central expression or condition (1-3 lines).",
1981
+ 2: "2 holes: a key condition and a key computation (2-6 lines each).",
1982
+ 3: "3 holes: the core logic of the main function(s) (3-12 lines each).",
1983
+ 4: "3-4 holes: whole function bodies of the most important logic."
1984
+ };
1985
+ function practiceSystem(level, hasConcept = false, multi = false) {
1986
+ return `TASK: practice
1987
+ You turn real code into a practice exercise that trains ONE concept. ${hasConcept ? "The learner named the concept; cut the lines where the code applies it." : "Name the one concept the code is built around (the idea worth learning), then cut the lines where the code applies it."}
1988
+ ${multi ? "The concept usually shows up in several files (definition, use, wiring, test). Spread the holes over the files where it matters, and say in each intent how this hole fits the whole." : ""}
1989
+ The holes are lines the learner must write back. Total size: ${LEVELS[level]}
1990
+ Rules: holes must not overlap; each hole is a range of whole lines (1-based, inclusive) from the numbers before "|"
1991
+ in ONE code file; set "file" to the path in that file's "=== FILE" header. CONTEXT FILES are read only.
1992
+ Good holes: a branch with a decision, a loop that builds or filters something, a transformation, error handling
1993
+ that changes the result, a guard that protects an invariant. Bad holes: imports, type declarations, constants and
1994
+ tables, logging, plain getters, glue, comments, closing brackets.
1995
+ Each hole must be solvable from the code around it: the learner sees the signature, the neighbouring lines and
1996
+ the names, but not the cut lines. Do not cut a line whose meaning cannot be guessed from the rest.
1997
+ intent: what the hole must do (inputs, outputs, edge cases) and what part of the concept it tests, without giving
1998
+ the code; 2-4 sentences. hints: 2-3, from gentle (where to look) to nearly the answer.
1999
+ concept: one sentence naming what this practice trains.
2000
+ ${STYLE}
2001
+
2002
+ JSON shape:
2003
+ { "concept": "...", "holes": [{"file": "src/a.ts", "startLine": 10, "endLine": 14, "intent": "...", "hints": ["...", "..."]}] }`;
2004
+ }
2005
+ function reviewSystem() {
2006
+ return `TASK: review
2007
+ You check a learner's attempt to fill holes in code. Compare each hole with the original. Judge behaviour,
2008
+ not style: a different but correct solution is "right"; pseudo-code that has the right logic is "partial" or
2009
+ "right". Feedback: 1-3 sentences per hole, say what differs and what it would break.
2010
+ ${STYLE}
2011
+
2012
+ JSON shape:
2013
+ { "summary": "1-2 sentences", "holes": [{"id": 1, "verdict": "right|partial|wrong", "feedback": "..."}] }`;
2014
+ }
2015
+ function reviewPrompt(files) {
2016
+ const list = files.flatMap((f) => f.holes.map((h) => `hole ${h.id}: file ${f.rel}, original lines ${h.startLine}-${h.endLine}; must: ${h.intent}`)).join("\n");
2017
+ const texts = files.map((f) => `=== ${f.rel} ===
2018
+ ORIGINAL:
2019
+ ${f.original}
2020
+
2021
+ LEARNER'S VERSION (holes filled in):
2022
+ ${f.attempt}`).join("\n\n");
2023
+ return `HOLES:
2024
+ ${list}
2025
+
2026
+ ${texts}`;
2027
+ }
2028
+ function teachbackSystem() {
2029
+ return `TASK: teachback
2030
+ The learner explains the given code in their own words, as if teaching a new teammate. Grade the explanation
2031
+ against the code. Be fair on wording; judge the mental model.
2032
+ right: claims that are correct and important. missing: important things a good explanation would have said.
2033
+ wrong: claims that are false, each as "You say X, but Y (line N)". score: 1-5 (1 = mostly wrong or empty,
2034
+ 3 = right but shallow, 4 = right and covers the important parts, 5 = also the why and the edge cases).
2035
+ ${STYLE}
2036
+
2037
+ JSON shape:
2038
+ { "score": 4, "summary": "1-2 sentences", "right": ["..."], "missing": ["..."], "wrong": ["..."] }`;
2039
+ }
2040
+ function teachbackPrompt(text2, files) {
2041
+ return `THE LEARNER'S EXPLANATION:
2042
+ ${text2}
2043
+
2044
+ CODE:
2045
+ ${files}`;
2046
+ }
2047
+ function tutorSystem(level, tell) {
2048
+ const how = tell ? "The learner asked you to tell them. Give the full answer now, clearly, with the lines that show it. Then ask one short question that checks they got it." : level <= 1 ? "Do NOT give the answer. Reply with ONE guiding question or a pointer to where to look (a line or a name), so the learner can find it." : level === 2 ? "Do NOT give the full answer yet. Give a concrete hint: name the mechanism or the line that matters and what to notice there. End with a question." : "The learner has tried twice. Walk through the answer step by step, but leave the last step for them, as a question.";
2049
+ return `TASK: tutor
2050
+ You are a Socratic tutor for code. The learner is trying to understand the code below. Never lecture.
2051
+ Keep replies short (2-6 sentences). Use the learner's own words back to them when they are close.
2052
+ If the learner states something wrong, do not just correct it: ask a question that makes them test it against the code.
2053
+ This turn: ${how}
2054
+ Name real files and line numbers from the input. Never invent code.
2055
+ Return ONLY one JSON object inside a single \`\`\`json fence.
2056
+
2057
+ JSON shape:
2058
+ { "reply": "markdown" }`;
2059
+ }
2060
+ function tutorPrompt(history, files, notes) {
2061
+ const h = history.map((m) => `${m.role === "user" ? "LEARNER" : "TUTOR"}: ${m.text}`).join("\n\n");
2062
+ return `CONVERSATION SO FAR:
2063
+ ${h}
2064
+
2065
+ ${notes ? `THE LEARNER'S NOTES:
2066
+ ${notes}
2067
+
2068
+ ` : ""}CODE:
2069
+ ${files}`;
2070
+ }
2071
+ function walkSystem() {
2072
+ return `TASK: walk
2073
+ Split the file into 4-10 blocks for a guided walkthrough, in READING order (the order that makes it easiest to
2074
+ understand: entry point and data shapes first, helpers when they are first needed), not file order.
2075
+ Each block is a range of whole lines (1-based, inclusive, from the numbers before "|"). Blocks may skip
2076
+ boilerplate (imports, logging). why: why this block matters and how it connects to the previous one (1-2 sentences).
2077
+ question: one check question about the block's behaviour or reason, answerable from the block in 1-2 sentences.
2078
+ Do not put the answer in the question.
2079
+ ${STYLE}
2080
+
2081
+ JSON shape:
2082
+ { "blocks": [{"startLine": 10, "endLine": 30, "title": "short", "why": "...", "question": "..."}] }`;
2083
+ }
2084
+ function walkPlanSystem() {
2085
+ return `TASK: walkPlan
2086
+ You are the PLANNER of a guided walkthrough of one file. A second writer model writes each block from your plan.
2087
+ Read the whole file (and, if you have file tools, its callers and tests) and split it into 5-12 blocks in READING
2088
+ order: entry point and data shapes first, helpers when they are first needed, not file order. A block is a range
2089
+ of whole lines (1-based, inclusive, from the numbers before "|"), usually 5-30 lines, that holds ONE idea. Skip
2090
+ boilerplate (imports, logging) unless it matters. Cover every branch, fallback and error path that matters.
2091
+ For each block decide what the writer must teach and which concrete example will make it click: a call with real
2092
+ values ("push(3) on an empty queue"), a small usage snippet, or a before/after. Plan a question that asks the
2093
+ learner to PREDICT or REASON about a concrete case ("what does it return for ...? why?"), never one that asks
2094
+ them to paraphrase the code.
2095
+ ${STYLE}
2096
+
2097
+ JSON shape:
2098
+ { "blocks": [{
2099
+ "startLine": 10, "endLine": 30, "title": "the idea as a short claim",
2100
+ "covers": ["each fact, branch or rule the block must explain, in order"],
2101
+ "example": "the concrete input and the result to walk through, or a small usage snippet to show",
2102
+ "question": "the question idea, about a concrete case"
2103
+ }] }`;
2104
+ }
2105
+ function walkBlocksSystem() {
2106
+ return `TASK: walkBlocks
2107
+ You are the WRITER of some blocks of a guided code walkthrough. A planner outlined every block; write only the
2108
+ blocks you are asked for, following the plan for each one. The page shows the block's own code above your text, so
2109
+ do not repeat the code; explain it.
2110
+ For each block write:
2111
+ - "why": 1-2 sentences: why this block matters and how it connects to the previous one.
2112
+ - "explain": markdown, 2-4 short paragraphs for a capable engineer who is new to this code: what it does, why it
2113
+ exists, why it is done this way, and what to notice. Identifiers in \`backticks\`. Second person.
2114
+ - "example": markdown. A worked example with real values: the call or input, what happens step by step through the
2115
+ block, and the result. Include at least one fenced code block (a small usage snippet, a tiny table of values, or
2116
+ before/after). The snippet may be illustrative calling code, but it must match the real names and signatures.
2117
+ - "question": one check question about a concrete case the learner must predict or reason about, answerable in 1-3
2118
+ sentences from the block and your text. Do not put the answer in the question.
2119
+ ${STYLE}
2120
+
2121
+ JSON shape:
2122
+ { "blocks": [{"index": 0, "why": "...", "explain": "...", "example": "...", "question": "..."}] } // one item per requested block, same "index" as in the plan (0-based)`;
2123
+ }
2124
+ function walkWritePrompt(plan, range, titles, files) {
2125
+ return `PLAN (from the planner; follow it):
2126
+ ${JSON.stringify(plan, null, 1)}
2127
+
2128
+ WRITE BLOCKS ${range[0]}-${range[1]} (0-based, both included).${titles.length ? `
2129
+ Earlier blocks, for continuity:
2130
+ ${titles.map((t) => `- ${t}`).join("\n")}` : ""}
2131
+
2132
+ CODE:
2133
+ ${files}`;
2134
+ }
2135
+ function walkcheckSystem() {
2136
+ return `TASK: walkcheck
2137
+ Grade one answer to a check question about a block of code. Lenient on wording: core idea right = "right".
2138
+ feedback: 1-2 sentences; if not right, say what the block really does and name the line.
2139
+ misconception (only for partial or wrong): what the learner seems to believe that is not true, one line, "You think X, but Y".
2140
+ ${STYLE}
2141
+
2142
+ JSON shape:
2143
+ { "verdict": "right|partial|wrong", "feedback": "...", "misconception": "..." }`;
2144
+ }
2145
+ function walkcheckPrompt(question, answer, block, file) {
2146
+ return `QUESTION: ${question}
2147
+ ANSWER: ${answer || "(no answer)"}
2148
+
2149
+ THE BLOCK:
2150
+ ${block}
2151
+
2152
+ WHOLE FILE FOR CONTEXT:
2153
+ ${file}`;
2154
+ }
2155
+ var HUNT_LEVELS = {
2156
+ 1: "1 bug, fairly visible to a careful reader (wrong comparison, off-by-one, wrong variable).",
2157
+ 2: "2 bugs: one visible, one that needs understanding of the logic (wrong default, swapped arguments, missing await, wrong boolean).",
2158
+ 3: "2-3 subtle bugs that only show with real understanding (edge case, ordering, state not reset, wrong branch taken)."
2159
+ };
2160
+ function huntSystem(level) {
2161
+ return `TASK: hunt
2162
+ Plant realistic bugs in the code for a debugging exercise. ${HUNT_LEVELS[level]}
2163
+ Each bug changes exactly ONE line. "original" must be that line copied exactly from the input (without the
2164
+ number and "|"). "buggy" is the full replacement line, same indent, still valid syntax, plausible as a real
2165
+ mistake (no comments that give it away, no weird names). Pick lines in the core logic, not imports or logging.
2166
+ "what": what the bug breaks, 1-2 sentences, for after the learner answers.
2167
+ ${STYLE}
2168
+
2169
+ JSON shape:
2170
+ { "bugs": [{"line": 12, "original": "exact line", "buggy": "changed line", "what": "..."}] }`;
2171
+ }
2172
+ function huntgradeSystem() {
2173
+ return `TASK: huntgrade
2174
+ A learner hunted for planted bugs. You get the planted bugs and the learner's marks (line + why).
2175
+ For each FOUND bug (the learner marked that line or the one next to it), judge their reason: feedback says if the
2176
+ reason is right and what the full effect is (1-2 sentences).
2177
+ For each FALSE ALARM (a mark on a line with no bug), explain in 1-2 sentences why that line is fine.
2178
+ ${STYLE}
2179
+
2180
+ JSON shape:
2181
+ { "found": [{"line": 12, "feedback": "..."}], "falseAlarms": [{"line": 40, "feedback": "..."}] }`;
2182
+ }
2183
+ function huntgradePrompt(bugs, found, falseAlarms, text2) {
2184
+ return `PLANTED BUGS:
2185
+ ${bugs.map((b) => `line ${b.line}: was \`${b.original.trim()}\`, now \`${b.buggy.trim()}\` (${b.what})`).join("\n")}
2186
+
2187
+ FOUND (learner's mark -> bug line):
2188
+ ${found.map((f) => `mark line ${f.line} -> bug line ${f.bugLine}: "${f.why}"`).join("\n") || "(none)"}
2189
+
2190
+ FALSE ALARMS:
2191
+ ${falseAlarms.map((f) => `line ${f.line}: "${f.why}"`).join("\n") || "(none)"}
2192
+
2193
+ THE FILE WITH THE BUGS:
2194
+ ${text2}`;
2195
+ }
2196
+ function pathSystem() {
2197
+ return `TASK: path
2198
+ Plan an onboarding path through this codebase for an engineer new to it: 6-12 stops, in the order to learn them.
2199
+ Start where the reader gets the big picture (entry points, core data shapes), then the most depended-on modules,
2200
+ then the interesting logic. Each stop: 1-4 files (relative paths EXACTLY as listed), why it comes now (1-2
2201
+ sentences), and the activity that fits: "lens" (read an explanation), "walk" (guided walkthrough of one file),
2202
+ "quiz" (check understanding), "teachback" (explain it yourself; good after 2-3 stops on one area).
2203
+ ${STYLE}
2204
+
2205
+ JSON shape:
2206
+ { "title": "short", "stops": [{"title": "short", "files": ["relative/path"], "why": "...", "activity": "lens|walk|quiz|teachback"}] }`;
2207
+ }
2208
+ function pathPrompt(files, hubs, docs, goal) {
2209
+ return `${goal ? `THE LEARNER'S GOAL: ${goal}
2210
+
2211
+ ` : ""}MOST IMPORTED FILES (import graph, most first):
2212
+ ${hubs.join("\n") || "(no import data)"}
2213
+
2214
+ ALL FILES:
2215
+ ${files.join("\n")}
2216
+
2217
+ ${docs ? `DOCS:
2218
+ ${docs}` : ""}`;
2219
+ }
2220
+ function parseJson(text2) {
2221
+ const fences = [...text2.matchAll(/```(?:json)?\s*\n([\s\S]*?)```/g)];
2222
+ const candidates = fences.map((m) => m[1]).reverse();
2223
+ const first = text2.indexOf("{");
2224
+ const last = text2.lastIndexOf("}");
2225
+ if (first >= 0 && last > first) candidates.push(text2.slice(first, last + 1));
2226
+ for (const c of candidates) {
2227
+ try {
2228
+ return JSON.parse(c);
2229
+ } catch {
2230
+ }
2231
+ }
2232
+ throw new Error(`model reply had no valid JSON: ${text2.slice(0, 300)}`);
2233
+ }
2234
+
2235
+ // src/core/schemas.ts
2236
+ import { z } from "zod";
2237
+ var text = z.string();
2238
+ var line = z.coerce.number().int().positive();
2239
+ var verdict = z.enum(["right", "partial", "wrong"]);
2240
+ var SCHEMAS = {
2241
+ lens: z.object({
2242
+ title: text,
2243
+ tldr: text.min(10),
2244
+ summary: text,
2245
+ concepts: z.array(z.object({ term: text, explain: text })).optional(),
2246
+ intuition: text.optional(),
2247
+ deepDives: z.array(z.object({ title: text, body: text })).min(1)
2248
+ }),
2249
+ lensPlan: z.object({
2250
+ title: text,
2251
+ tldr: text.min(10),
2252
+ steps: z.array(z.object({ title: text })).min(1)
2253
+ }),
2254
+ lensIntro: z.object({ summary: text, intuition: text.optional() }),
2255
+ lensSteps: z.object({ steps: z.array(z.object({ body: text })).min(1) }),
2256
+ plan: z.object({ outline: text.min(10) }),
2257
+ lensSketch: z.object({ differences: z.array(text).min(1) }),
2258
+ lensTrace: z.object({
2259
+ trace: z.object({ input: text, steps: z.array(z.object({ note: text, state: z.record(z.unknown()).optional() })).min(1) })
2260
+ }),
2261
+ quiz: z.object({ questions: z.array(z.object({ prompt: text })).min(1) }),
2262
+ grade: z.object({ grades: z.array(z.object({ qid: text, verdict })).min(1) }),
2263
+ practice: z.object({ holes: z.array(z.object({ startLine: line, endLine: line, intent: text })).min(1) }),
2264
+ review: z.object({ holes: z.array(z.object({ id: z.coerce.number(), verdict })) }),
2265
+ teachback: z.object({ score: z.coerce.number().min(1).max(5), right: z.array(text), missing: z.array(text), wrong: z.array(text) }),
2266
+ tutor: z.object({ reply: text.min(1) }),
2267
+ walk: z.object({ blocks: z.array(z.object({ startLine: line, endLine: line, question: text })).min(2) }),
2268
+ walkPlan: z.object({ blocks: z.array(z.object({ startLine: line, endLine: line, title: text })).min(2) }),
2269
+ walkBlocks: z.object({ blocks: z.array(z.object({ question: text })).min(1) }),
2270
+ walkcheck: z.object({ verdict, feedback: text }),
2271
+ hunt: z.object({ bugs: z.array(z.object({ line, original: text, buggy: text, what: text })).min(1) }),
2272
+ huntgrade: z.object({ found: z.array(z.object({ line })), falseAlarms: z.array(z.object({ line })) }),
2273
+ path: z.object({ title: text, stops: z.array(z.object({ title: text, files: z.array(text).min(1), why: text })).min(2) })
2274
+ };
2275
+ function problems(task, value) {
2276
+ const r = SCHEMAS[task].safeParse(value);
2277
+ if (r.success) return null;
2278
+ return r.error.issues.slice(0, 6).map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
2279
+ }
2280
+
2281
+ // src/core/services.ts
2282
+ var VERDICTS = ["right", "partial", "wrong"];
2283
+ var str = (v, max = 4e3) => typeof v === "string" ? v.slice(0, max) : "";
2284
+ var arr = (v) => Array.isArray(v) ? v : [];
2285
+ var num = (v) => {
2286
+ const n = typeof v === "string" ? Number(v) : v;
2287
+ return typeof n === "number" && Number.isFinite(n) ? Math.round(n) : void 0;
2288
+ };
2289
+ var verdict2 = (v) => VERDICTS.includes(v) ? v : "wrong";
2290
+ var strs = (v, max = 600, n = 12) => arr(v).map((x) => str(x, max)).filter(Boolean).slice(0, n);
2291
+ var now = () => (/* @__PURE__ */ new Date()).toISOString();
2292
+ var ACTIVITIES = ["lens", "walk", "quiz", "teachback"];
2293
+ var Learn = class {
2294
+ constructor(store2, agent) {
2295
+ this.store = store2;
2296
+ this.agent = agent;
2297
+ this.cache = new ReplyCache(store2);
2298
+ }
2299
+ cache;
2300
+ settings() {
2301
+ return this.store.settings();
2302
+ }
2303
+ /**
2304
+ * One model call, parsed and checked. `cache` reuses an earlier reply to the exact same prompt
2305
+ * (a lens on an unchanged file is free the second time).
2306
+ */
2307
+ async ask(ws, tasks, system, prompt0, opts2 = {}) {
2308
+ const key = opts2.cache ? this.cache.key(this.agent.name, system, prompt0) : "";
2309
+ let prompt = prompt0;
2310
+ if (opts2.plan && this.settings()["generate.plan"]) {
2311
+ const o = await this.ask(ws, ["plan"], planSystem(system), prompt0, { role: "plan" });
2312
+ prompt = `${prompt0}
2313
+
2314
+ PLAN (from the planner; follow it):
2315
+ ${o.outline}`;
2316
+ }
2317
+ const hit = key ? this.cache.get(key) : void 0;
2318
+ if (hit) {
2319
+ try {
2320
+ const v = parseJson(hit);
2321
+ if (tasks.every((t) => !problems(t, v))) return v;
2322
+ } catch {
2323
+ }
2324
+ }
2325
+ let text2 = await this.agent.complete({ system, prompt, cwd: ws.project.root, role: opts2.role });
2326
+ let value;
2327
+ let issue = null;
2328
+ try {
2329
+ value = parseJson(text2);
2330
+ issue = tasks.map((t) => problems(t, value)).find(Boolean) ?? null;
2331
+ } catch (e) {
2332
+ issue = e instanceof Error ? e.message.slice(0, 200) : "no JSON";
2333
+ }
2334
+ if (issue) {
2335
+ const retry = `${prompt}
2336
+
2337
+ YOUR LAST REPLY COULD NOT BE USED: ${issue}
2338
+ Reply again with the full JSON object in the required shape.`;
2339
+ text2 = await this.agent.complete({ system, prompt: retry, cwd: ws.project.root, role: opts2.role });
2340
+ value = parseJson(text2);
2341
+ const again = tasks.map((t) => problems(t, value)).find(Boolean);
2342
+ if (again) throw new Error(`the model reply did not have the expected shape: ${again}`);
2343
+ }
2344
+ if (key) this.cache.set(key, text2);
2345
+ return value;
2346
+ }
2347
+ bundle(ws, files) {
2348
+ return ws.bundle(files, this.settings()["context.maxBytes"]);
2349
+ }
2350
+ /** The learner's margin notes on these files, when the setting is on. */
2351
+ notes(ws, files) {
2352
+ if (!this.settings()["notes.inPrompts"]) return void 0;
2353
+ const want = new Set(files);
2354
+ const lines = this.store.list(ws.project.id, "notes").filter((n) => n.file && want.has(n.file)).map((n) => `${ws.rel(n.file)}${n.line ? `:${n.line}` : ""}: ${n.text}`);
2355
+ return lines.length ? lines.join("\n").slice(0, 8e3) : void 0;
2356
+ }
2357
+ diffText(ws, diff, files) {
2358
+ if (!diff) return void 0;
2359
+ const want = new Set(files);
2360
+ const patches = ws.diff(diff).filter((f) => want.size === 0 || want.has(f.path)).map((f) => f.patch);
2361
+ return patches.join("\n").slice(0, this.settings()["context.maxBytes"] / 2);
2362
+ }
2363
+ hashes(ws, files) {
2364
+ return Object.fromEntries(files.map((f) => [f, ws.hash(f) ?? ""]));
2365
+ }
2366
+ /** Rough size of a prompt for these files, for the "~N tokens" line before a job. */
2367
+ estimate(project, files) {
2368
+ const ws = new Workspace(project);
2369
+ const max = this.settings()["context.maxBytes"];
2370
+ const text2 = ws.bundle(files.map((f) => ws.guard(f)), max);
2371
+ return { bytes: text2.length, tokens: Math.round(text2.length / 3.5), cut: text2.includes("(Not included, too large") };
2372
+ }
2373
+ // ---- lens ------------------------------------------------------------
2374
+ async lens(project, req) {
2375
+ const ws = new Workspace(project);
2376
+ const old = req.refreshOf ? this.store.get(project.id, "lenses", req.refreshOf) : void 0;
2377
+ if (req.refreshOf && !old) throw new Error("lens not found");
2378
+ const r = old ? { files: old.files, kind: old.kind, question: old.question, diff: old.diff, sketch: void 0, traceInput: old.trace?.input, depth: old.depth } : req;
2379
+ const depth = r.depth === "overview" || r.depth === "deep" ? r.depth : "standard";
2380
+ const files = r.files.map((f) => ws.guard(f));
2381
+ if (!files.length) throw new Error("pick at least one file");
2382
+ const kind = LENS_KINDS.find((k) => k.id === r.kind);
2383
+ const question = r.question?.trim() || kind?.question || LENS_KINDS[0].question;
2384
+ const sketch = r.sketch?.trim() || void 0;
2385
+ const trace = kind?.id === "trace";
2386
+ const max = this.settings()["context.maxBytes"];
2387
+ const extras = {
2388
+ diff: this.diffText(ws, r.diff, files),
2389
+ sketch,
2390
+ traceInput: trace ? (r.traceInput ?? "").trim() : void 0,
2391
+ history: kind?.id === "history" ? ws.history(files[0], max / 2) : void 0,
2392
+ previous: old ? `${old.summary}
2393
+ ${old.deepDives.map((d) => `- ${d.title}`).join("\n")}` : void 0,
2394
+ notes: this.notes(ws, files)
2395
+ };
2396
+ const tasks = ["lens", ...sketch ? ["lensSketch"] : [], ...trace ? ["lensTrace"] : []];
2397
+ const cache = !old && !sketch;
2398
+ const raw = this.settings()["generate.plan"] ? await this.lensStaged(ws, { question, files, depth, extras, sketch: !!sketch, trace, cache }) : await this.ask(
2399
+ ws,
2400
+ tasks,
2401
+ lensSystem({ sketch: !!sketch, trace, depth }),
2402
+ lensPrompt(question, this.bundle(ws, files), extras),
2403
+ { cache }
2404
+ );
2405
+ const lens = {
2406
+ id: newId(),
2407
+ projectId: project.id,
2408
+ createdAt: now(),
2409
+ kind: kind?.id ?? "custom",
2410
+ question,
2411
+ files,
2412
+ diff: r.diff,
2413
+ title: str(raw.title, 120) || question,
2414
+ summary: str(raw.summary),
2415
+ concepts: arr(raw.concepts).map((c) => {
2416
+ const o = c;
2417
+ return {
2418
+ term: str(o.term, 120),
2419
+ explain: str(o.explain),
2420
+ example: str(o.example, 1e3) || void 0,
2421
+ file: ws.absFromRel(str(o.file, 500)),
2422
+ line: num(o.line)
2423
+ };
2424
+ }).filter((c) => c.term),
2425
+ intuition: str(raw.intuition, 12e3),
2426
+ deepDives: arr(raw.deepDives).map((d) => cleanDive(d, ws)).filter((d) => d.title || d.body),
2427
+ tldr: str(raw.tldr, 400) || void 0,
2428
+ whyItMatters: str(raw.whyItMatters, 800) || void 0,
2429
+ gotchas: strs(raw.gotchas, 400, 8),
2430
+ nextSteps: arr(raw.nextSteps).map((n) => {
2431
+ const o = n;
2432
+ return { file: ws.absFromRel(str(o.file, 500)) ?? "", why: str(o.why, 400) };
2433
+ }).filter((n) => n.file).slice(0, 4),
2434
+ depth,
2435
+ fileHashes: this.hashes(ws, files),
2436
+ sketch,
2437
+ differences: sketch ? strs(raw.differences) : void 0,
2438
+ trace: trace ? cleanTrace(raw.trace, ws) : void 0
2439
+ };
2440
+ this.store.put(project.id, "lenses", lens);
2441
+ if (old) this.store.archive(project.id, "lenses", old.id);
2442
+ return lens;
2443
+ }
2444
+ /**
2445
+ * A lens in parts instead of one call: the planner outlines (title, tldr, every step with its lines and
2446
+ * what it must cover), then the writer fills in the opening and the steps, a few steps per call, side by
2447
+ * side. The result has the same shape as the one-shot reply, so the caller does not care which ran.
2448
+ */
2449
+ async lensStaged(ws, x) {
2450
+ const code = this.bundle(ws, x.files);
2451
+ const plan = await this.ask(
2452
+ ws,
2453
+ ["lensPlan"],
2454
+ lensPlanSystem({ depth: x.depth, trace: x.trace }),
2455
+ lensPrompt(x.question, code, x.extras),
2456
+ { cache: x.cache, role: "plan" }
2457
+ );
2458
+ const planned = arr(plan.steps).map((st) => st);
2459
+ const introTasks = ["lensIntro", ...x.sketch ? ["lensSketch"] : [], ...x.trace ? ["lensTrace"] : []];
2460
+ const chunk = x.depth === "overview" ? 4 : 3;
2461
+ const ranges = [];
2462
+ for (let i = 0; i < planned.length; i += chunk) ranges.push([i, Math.min(planned.length, i + chunk) - 1]);
2463
+ const jobs = [
2464
+ () => this.ask(
2465
+ ws,
2466
+ introTasks,
2467
+ lensIntroSystem({ depth: x.depth, sketch: x.sketch, trace: x.trace }),
2468
+ lensWritePrompt(x.question, plan, code, { extras: x.extras }),
2469
+ { cache: x.cache }
2470
+ ),
2471
+ ...ranges.map((r) => () => this.ask(
2472
+ ws,
2473
+ ["lensSteps"],
2474
+ lensStepsSystem({ depth: x.depth }),
2475
+ lensWritePrompt(x.question, plan, code, { steps: r, titles: planned.slice(0, r[0]).map((st) => str(st.title, 200)) }),
2476
+ { cache: x.cache }
2477
+ ))
2478
+ ];
2479
+ const [intro, ...written] = await inParallel(jobs, 3);
2480
+ const bodies = /* @__PURE__ */ new Map();
2481
+ written.forEach((w, n) => arr(w.steps).forEach((st, k) => {
2482
+ const o = st;
2483
+ bodies.set(num(o.index) ?? ranges[n][0] + k, o);
2484
+ }));
2485
+ const deepDives = planned.map((st, i) => {
2486
+ const w = bodies.get(i) ?? {};
2487
+ const covers = strs(st.covers, 400, 12);
2488
+ return {
2489
+ title: st.title,
2490
+ file: st.file,
2491
+ line: st.line,
2492
+ startLine: st.startLine,
2493
+ endLine: st.endLine,
2494
+ // If a part never came back, fall back to the plan's own notes so the step is not empty.
2495
+ body: str(w.body, 12e3) || covers.map((c) => `- ${c}`).join("\n") || str(st.why, 800),
2496
+ pitfall: w.pitfall ?? (str(st.pitfall, 800) || void 0),
2497
+ check: w.check
2498
+ };
2499
+ });
2500
+ return {
2501
+ ...intro,
2502
+ title: plan.title,
2503
+ tldr: plan.tldr,
2504
+ whyItMatters: plan.whyItMatters,
2505
+ concepts: arr(intro.concepts).length ? intro.concepts : arr(plan.concepts).map((c) => ({ ...c, explain: str(c.note) })),
2506
+ gotchas: arr(intro.gotchas).length ? intro.gotchas : plan.gotchas,
2507
+ nextSteps: arr(intro.nextSteps).length ? intro.nextSteps : plan.nextSteps,
2508
+ deepDives
2509
+ };
2510
+ }
2511
+ // ---- quiz ------------------------------------------------------------
2512
+ async quiz(project, req) {
2513
+ const ws = new Workspace(project);
2514
+ const lens = req.lensId ? this.store.get(project.id, "lenses", req.lensId) : void 0;
2515
+ const weak = req.weak ? openMisconceptions(this.store, project.id).slice(0, 6) : [];
2516
+ if (req.weak && !weak.length) throw new Error("no open weak spots: nothing to aim at yet");
2517
+ const weakFiles = weak.map((m) => m.file).filter((f) => !!f);
2518
+ const files = [...new Set((req.files.length ? req.files : lens?.files ?? weakFiles).map((f) => ws.guard(f)))];
2519
+ if (!files.length && !weak.length) throw new Error("pick at least one file");
2520
+ const n = Math.min(Math.max(req.count ?? (weak.length || this.settings()["quiz.questions"]), 1), 10);
2521
+ const raw = await this.ask(
2522
+ ws,
2523
+ ["quiz"],
2524
+ quizSystem(n),
2525
+ quizPrompt(this.bundle(ws, files), this.diffText(ws, req.diff ?? lens?.diff, files), lens?.summary, weak.map((m) => ({ id: m.id, text: m.text }))),
2526
+ { plan: true }
2527
+ );
2528
+ const weakIds = new Set(weak.map((m) => m.id));
2529
+ const questions = arr(raw.questions).slice(0, n).map((q, i) => {
2530
+ const o = q;
2531
+ const target = str(o.targets, 40);
2532
+ return {
2533
+ id: `q${i + 1}`,
2534
+ prompt: str(o.prompt, 2e3),
2535
+ file: ws.absFromRel(str(o.file, 500)),
2536
+ line: num(o.line),
2537
+ focus: str(o.focus, 120),
2538
+ misconceptionId: weakIds.has(target) ? target : void 0
2539
+ };
2540
+ }).filter((q) => q.prompt);
2541
+ if (!questions.length) throw new Error("the model wrote no questions");
2542
+ const quiz = {
2543
+ id: newId(),
2544
+ projectId: project.id,
2545
+ createdAt: now(),
2546
+ title: str(raw.title, 120) || (weak.length ? "Your weak spots" : "Quiz"),
2547
+ files,
2548
+ fileHashes: this.hashes(ws, files),
2549
+ diff: req.diff ?? lens?.diff,
2550
+ lensId: lens?.id,
2551
+ questions,
2552
+ attempts: []
2553
+ };
2554
+ return this.store.put(project.id, "quizzes", quiz);
2555
+ }
2556
+ async grade(project, quizId, answers) {
2557
+ const ws = new Workspace(project);
2558
+ const quiz = this.store.get(project.id, "quizzes", quizId);
2559
+ if (!quiz) throw new Error("quiz not found");
2560
+ const byId = new Map(answers.map((a) => [a.qid, a]));
2561
+ const items2 = quiz.questions.map((q) => ({ qid: q.id, question: q.prompt, answer: str(byId.get(q.id)?.answer, 4e3) }));
2562
+ const raw = await this.ask(ws, ["grade"], gradeSystem(), gradePrompt(items2, this.bundle(ws, quiz.files)));
2563
+ const got = new Map(arr(raw.grades).map((g) => {
2564
+ const o = g;
2565
+ return [str(o.qid, 20), o];
2566
+ }));
2567
+ const grades = quiz.questions.map((q) => {
2568
+ const o = got.get(q.id) ?? {};
2569
+ const answered = !!byId.get(q.id)?.answer?.trim();
2570
+ const v = answered ? verdict2(o.verdict) : "wrong";
2571
+ const mis = v !== "right" && answered ? str(o.misconception, 500) : "";
2572
+ return { qid: q.id, verdict: v, feedback: str(o.feedback), modelAnswer: str(o.modelAnswer), ...mis ? { misconception: mis } : {} };
2573
+ });
2574
+ const attempt = {
2575
+ at: now(),
2576
+ answers: quiz.questions.map((q) => ({ qid: q.id, answer: byId.get(q.id)?.answer ?? "", sure: !!byId.get(q.id)?.sure })),
2577
+ grades,
2578
+ score: scoreOf(grades)
2579
+ };
2580
+ quiz.attempts.push(attempt);
2581
+ this.store.put(project.id, "quizzes", quiz);
2582
+ this.afterGrade(project, quiz, grades);
2583
+ return quiz;
2584
+ }
2585
+ /** Misses become misconceptions and review cards; a right answer resolves what it targeted. */
2586
+ afterGrade(project, quiz, grades) {
2587
+ const pid = project.id;
2588
+ const cards = this.settings()["cards.fromMisses"] ? this.store.list(pid, "cards") : null;
2589
+ for (const g of grades) {
2590
+ const q = quiz.questions.find((x) => x.id === g.qid);
2591
+ if (!q) continue;
2592
+ const sourceId = `${quiz.id}:${q.id}`;
2593
+ if (g.verdict === "right") {
2594
+ resolveMisconception(this.store, pid, (m) => m.id === q.misconceptionId || m.sourceId === sourceId);
2595
+ continue;
2596
+ }
2597
+ if (g.misconception) recordMisconception(this.store, pid, { text: g.misconception, file: q.file, line: q.line, from: "quiz", sourceId });
2598
+ if (cards && g.modelAnswer && !cards.some((c) => c.source?.quizId === quiz.id && c.source?.qid === q.id)) {
2599
+ this.store.put(pid, "cards", makeCard(pid, { kind: "miss", front: q.prompt, back: g.modelAnswer, file: q.file, line: q.line, source: { quizId: quiz.id, qid: q.id } }));
2600
+ }
2601
+ }
2602
+ }
2603
+ // ---- practice --------------------------------------------------------
2604
+ async practice(project, req) {
2605
+ const ws = new Workspace(project);
2606
+ const level = Math.min(Math.max(Math.round(req.level || 2), 1), 4);
2607
+ const paths = [...new Set([...req.files ?? [], ...req.file ? [req.file] : []].map((f) => ws.guard(f)))];
2608
+ const DATA = /\.(json|ya?ml|toml|ini|cfg|lock|csv|tsv|md|txt|svg|xml|html?|css)$/i;
2609
+ const code = paths.filter((f) => !DATA.test(f));
2610
+ if (!code.length) throw new Error("practice needs at least one file with logic (code). These are data, config or text; add a source file.");
2611
+ const texts = new Map(paths.map((f) => [f, ws.read(f).text]));
2612
+ const concept = (req.concept ?? "").trim().slice(0, 300);
2613
+ const prompt = (concept ? `CONCEPT TO PRACTISE: ${concept}
2614
+
2615
+ ` : "") + `CODE FILES (holes may be cut from these):
2616
+ ${this.bundle(ws, code)}` + (paths.length > code.length ? `
2617
+
2618
+ CONTEXT FILES (read only, no holes):
2619
+ ${this.bundle(ws, paths.filter((f) => DATA.test(f)))}` : "");
2620
+ const raw = await this.ask(ws, ["practice"], practiceSystem(level, !!concept, code.length > 1), prompt, { plan: true });
2621
+ const byFile = /* @__PURE__ */ new Map();
2622
+ for (const h of arr(raw.holes)) {
2623
+ const want = str(h.file, 500);
2624
+ const hit = want ? code.find((f) => ws.rel(f) === want || ws.rel(f).endsWith(want) || want.endsWith(ws.rel(f))) : code.length === 1 ? code[0] : void 0;
2625
+ if (hit) byFile.set(hit, [...byFile.get(hit) ?? [], h]);
2626
+ }
2627
+ const holes = [];
2628
+ const files = [];
2629
+ for (const f of paths) {
2630
+ const original = texts.get(f);
2631
+ const lines = original.split("\n");
2632
+ const mine = cleanHoles(byFile.get(f) ?? [], lines.length, lines).map((h, i) => ({ ...h, id: holes.length + i + 1, file: f }));
2633
+ holes.push(...mine);
2634
+ const holed = mine.length ? applyHoles(original, mine, langOf(f)) : original;
2635
+ files.push({ path: f, original, holed, attempt: holed });
2636
+ }
2637
+ if (!holes.length) throw new Error("the model found no lines with real logic to turn into holes; try other files or a higher level");
2638
+ files.sort((x, y) => Number(holes.some((h) => h.file === y.path)) - Number(holes.some((h) => h.file === x.path)));
2639
+ const first = files[0];
2640
+ const p = {
2641
+ id: newId(),
2642
+ projectId: project.id,
2643
+ createdAt: now(),
2644
+ concept: concept || str(raw.concept, 300) || void 0,
2645
+ files,
2646
+ file: first.path,
2647
+ level,
2648
+ original: first.original,
2649
+ holed: first.holed,
2650
+ holes,
2651
+ attempt: first.holed
2652
+ };
2653
+ return this.store.put(project.id, "practice", p);
2654
+ }
2655
+ saveAttempt(project, id, text2, file) {
2656
+ const got = this.store.get(project.id, "practice", id);
2657
+ if (!got) throw new Error("practice not found");
2658
+ const p = normalizePractice(got);
2659
+ const f = p.files.find((x) => x.path === file) ?? p.files[0];
2660
+ f.attempt = text2.slice(0, 1e6);
2661
+ if (f.path === p.file) p.attempt = f.attempt;
2662
+ return this.store.put(project.id, "practice", p);
2663
+ }
2664
+ async review(project, id) {
2665
+ const ws = new Workspace(project);
2666
+ const got = this.store.get(project.id, "practice", id);
2667
+ if (!got) throw new Error("practice not found");
2668
+ const p = normalizePractice(got);
2669
+ const parts = p.files.map((f) => ({ rel: ws.rel(f.path), original: f.original, attempt: f.attempt, holes: p.holes.filter((h) => h.file === f.path) })).filter((f) => f.holes.length);
2670
+ const raw = await this.ask(ws, ["review"], reviewSystem(), reviewPrompt(parts));
2671
+ const got2 = new Map(arr(raw.holes).map((h) => [num(h.id), h]));
2672
+ const holes = p.holes.map((h) => {
2673
+ const o = got2.get(h.id) ?? {};
2674
+ return { id: h.id, verdict: verdict2(o.verdict), feedback: str(o.feedback) };
2675
+ });
2676
+ p.review = { at: now(), holes, summary: str(raw.summary) };
2677
+ return this.store.put(project.id, "practice", p);
2678
+ }
2679
+ // ---- teach-back ------------------------------------------------------
2680
+ async teachback(project, req) {
2681
+ const ws = new Workspace(project);
2682
+ const files = req.files.map((f) => ws.guard(f));
2683
+ if (!files.length) throw new Error("pick at least one file");
2684
+ const text2 = (req.text ?? "").trim().slice(0, 2e4);
2685
+ if (text2.length < 20) throw new Error("write a few sentences first");
2686
+ const raw = await this.ask(ws, ["teachback"], teachbackSystem(), teachbackPrompt(text2, this.bundle(ws, files)));
2687
+ const t = {
2688
+ id: newId(),
2689
+ projectId: project.id,
2690
+ createdAt: now(),
2691
+ files,
2692
+ fileHashes: this.hashes(ws, files),
2693
+ text: text2,
2694
+ grade: {
2695
+ score: Math.min(5, Math.max(1, num(raw.score) ?? 1)),
2696
+ summary: str(raw.summary, 800),
2697
+ right: strs(raw.right),
2698
+ missing: strs(raw.missing),
2699
+ wrong: strs(raw.wrong)
2700
+ }
2701
+ };
2702
+ this.store.put(project.id, "teachbacks", t);
2703
+ t.grade.wrong.forEach((w, i) => recordMisconception(this.store, project.id, { text: w, file: files[0], from: "teachback", sourceId: `${t.id}:${i}` }));
2704
+ return t;
2705
+ }
2706
+ // ---- tutor -------------------------------------------------------------
2707
+ async startThread(project, req) {
2708
+ const ws = new Workspace(project);
2709
+ const files = req.files.map((f) => ws.guard(f));
2710
+ if (!files.length) throw new Error("pick at least one file");
2711
+ const text2 = (req.text ?? "").trim();
2712
+ if (!text2) throw new Error("ask a question first");
2713
+ const t = { id: newId(), projectId: project.id, createdAt: now(), title: text2.split("\n")[0].slice(0, 80), files, messages: [] };
2714
+ return this.tutorTurn(project, t, text2, false);
2715
+ }
2716
+ async tutorReply(project, id, text2, tell) {
2717
+ const t = this.store.get(project.id, "threads", id);
2718
+ if (!t) throw new Error("thread not found");
2719
+ return this.tutorTurn(project, t, (text2 ?? "").trim() || (tell ? "Just tell me." : ""), tell);
2720
+ }
2721
+ async tutorTurn(project, t, text2, tell) {
2722
+ if (!text2) throw new Error("write something first");
2723
+ const ws = new Workspace(project);
2724
+ t.messages.push({ role: "user", text: text2.slice(0, 8e3), at: now() });
2725
+ const since = t.messages.slice(t.messages.map((m) => m.hintLevel === 4).lastIndexOf(true) + 1);
2726
+ const level = Math.min(3, since.filter((m) => m.role === "tutor").length + 1);
2727
+ const raw = await this.ask(ws, ["tutor"], tutorSystem(level, tell), tutorPrompt(t.messages, this.bundle(ws, t.files), this.notes(ws, t.files)));
2728
+ t.messages.push({ role: "tutor", text: str(raw.reply, 8e3), at: now(), hintLevel: tell ? 4 : level });
2729
+ if (t.messages.length > THREAD_MESSAGE_CAP) t.messages = [t.messages[0], ...t.messages.slice(-(THREAD_MESSAGE_CAP - 1))];
2730
+ return this.store.put(project.id, "threads", t);
2731
+ }
2732
+ // ---- walkthrough ---------------------------------------------------------
2733
+ async walk(project, req) {
2734
+ const ws = new Workspace(project);
2735
+ const file = ws.guard(req.file);
2736
+ const content = ws.read(file);
2737
+ const lines = content.text.split("\n").length;
2738
+ const code = this.bundle(ws, [file]);
2739
+ let rawBlocks;
2740
+ if (this.settings()["generate.plan"]) {
2741
+ const plan = await this.ask(ws, ["walkPlan"], walkPlanSystem(), `CODE:
2742
+ ${code}`, { cache: true, role: "plan" });
2743
+ const planned = arr(plan.blocks).slice(0, 14).map((b) => b);
2744
+ const ranges = [];
2745
+ for (let i = 0; i < planned.length; i += 3) ranges.push([i, Math.min(planned.length, i + 3) - 1]);
2746
+ const written = await inParallel(
2747
+ ranges.map((r) => () => this.ask(
2748
+ ws,
2749
+ ["walkBlocks"],
2750
+ walkBlocksSystem(),
2751
+ walkWritePrompt({ blocks: planned }, r, planned.slice(0, r[0]).map((b) => str(b.title, 160)), code),
2752
+ { cache: true }
2753
+ )),
2754
+ 3
2755
+ );
2756
+ const byIndex = /* @__PURE__ */ new Map();
2757
+ written.forEach((w, n) => arr(w.blocks).forEach((b, k) => {
2758
+ const o = b;
2759
+ byIndex.set(num(o.index) ?? ranges[n][0] + k, o);
2760
+ }));
2761
+ rawBlocks = planned.map((b, i) => ({ ...b, ...byIndex.get(i) ?? {}, question: str(byIndex.get(i)?.question, 800) || str(b.question, 800) }));
2762
+ } else {
2763
+ rawBlocks = (await this.ask(ws, ["walk"], walkSystem(), `CODE:
2764
+ ${code}`, { cache: true })).blocks ?? [];
2765
+ }
2766
+ const blocks = arr(rawBlocks).map((b) => {
2767
+ const o = b;
2768
+ const startLine = num(o.startLine) ?? 0;
2769
+ return {
2770
+ startLine,
2771
+ endLine: Math.min(lines, num(o.endLine) ?? startLine),
2772
+ title: str(o.title, 120),
2773
+ why: str(o.why, 800),
2774
+ explain: str(o.explain, 6e3) || void 0,
2775
+ example: str(o.example, 6e3) || void 0,
2776
+ question: str(o.question, 800)
2777
+ };
2778
+ }).filter((b) => b.startLine >= 1 && b.endLine >= b.startLine && b.question).slice(0, 12);
2779
+ if (blocks.length < 2) throw new Error("the model could not split this file into blocks");
2780
+ return this.store.put(project.id, "walks", { id: newId(), projectId: project.id, createdAt: now(), file, fileHash: content.hash, blocks });
2781
+ }
2782
+ async walkcheck(project, id, index, answer) {
2783
+ const ws = new Workspace(project);
2784
+ const w = this.store.get(project.id, "walks", id);
2785
+ if (!w) throw new Error("walkthrough not found");
2786
+ const b = w.blocks[index];
2787
+ if (!b) throw new Error("block not found");
2788
+ const text2 = ws.read(w.file).text.split("\n");
2789
+ const block = text2.slice(b.startLine - 1, b.endLine).map((l, i) => `${String(b.startLine + i).padStart(4)}| ${l}`).join("\n");
2790
+ const raw = await this.ask(ws, ["walkcheck"], walkcheckSystem(), walkcheckPrompt(b.question, answer, block, this.bundle(ws, [w.file])));
2791
+ b.answer = answer.slice(0, 4e3);
2792
+ b.verdict = answer.trim() ? verdict2(raw.verdict) : "wrong";
2793
+ b.feedback = str(raw.feedback, 1500);
2794
+ this.store.put(project.id, "walks", w);
2795
+ const mis = str(raw.misconception, 500);
2796
+ if (b.verdict !== "right" && mis && answer.trim()) recordMisconception(this.store, project.id, { text: mis, file: w.file, line: b.startLine, from: "walk", sourceId: `${w.id}:${index}` });
2797
+ if (b.verdict === "right") resolveMisconception(this.store, project.id, (m) => m.sourceId === `${w.id}:${index}`);
2798
+ return w;
2799
+ }
2800
+ // ---- bug hunt --------------------------------------------------------------
2801
+ async hunt(project, req) {
2802
+ const ws = new Workspace(project);
2803
+ const file = ws.guard(req.file);
2804
+ const content = ws.read(file);
2805
+ const level = Math.min(3, Math.max(1, Math.round(req.level || 1)));
2806
+ const raw = await this.ask(ws, ["hunt"], huntSystem(level), `CODE:
2807
+ ${this.bundle(ws, [file])}`, { plan: true });
2808
+ const { text: text2, planted } = plantBugs(content.text, arr(raw.bugs));
2809
+ if (!planted.length) throw new Error("the model's bugs did not match the file; try again");
2810
+ return this.store.put(project.id, "hunts", {
2811
+ id: newId(),
2812
+ projectId: project.id,
2813
+ createdAt: now(),
2814
+ file,
2815
+ fileHash: content.hash,
2816
+ level,
2817
+ text: text2,
2818
+ planted,
2819
+ marks: []
2820
+ });
2821
+ }
2822
+ saveMarks(project, id, marks) {
2823
+ const h = this.store.get(project.id, "hunts", id);
2824
+ if (!h) throw new Error("bug hunt not found");
2825
+ if (h.grade) throw new Error("already graded");
2826
+ h.marks = marks.map((m) => ({ line: num(m.line) ?? 0, why: str(m.why, 1e3) })).filter((m) => m.line >= 1).slice(0, 20);
2827
+ return this.store.put(project.id, "hunts", h);
2828
+ }
2829
+ async huntgrade(project, id) {
2830
+ const ws = new Workspace(project);
2831
+ const h = this.store.get(project.id, "hunts", id);
2832
+ if (!h) throw new Error("bug hunt not found");
2833
+ const { found, falseAlarms, missed } = matchMarks(h.planted, h.marks);
2834
+ let fb = /* @__PURE__ */ new Map();
2835
+ let fa = /* @__PURE__ */ new Map();
2836
+ if (found.length || falseAlarms.length) {
2837
+ const numbered = h.text.split("\n").map((l, i) => `${String(i + 1).padStart(4)}| ${l}`).join("\n");
2838
+ const raw = await this.ask(
2839
+ ws,
2840
+ ["huntgrade"],
2841
+ huntgradeSystem(),
2842
+ huntgradePrompt(h.planted, found.map((f) => ({ line: f.mark.line, why: f.mark.why, bugLine: f.bug.line })), falseAlarms, numbered)
2843
+ );
2844
+ fb = new Map(arr(raw.found).map((x) => [num(x.line) ?? 0, str(x.feedback, 1e3)]));
2845
+ fa = new Map(arr(raw.falseAlarms).map((x) => [num(x.line) ?? 0, str(x.feedback, 1e3)]));
2846
+ }
2847
+ const score = Math.max(0, (found.length - 0.5 * falseAlarms.length) / h.planted.length);
2848
+ h.grade = {
2849
+ at: now(),
2850
+ found: found.map((f) => ({ line: f.bug.line, what: f.bug.what, feedback: fb.get(f.bug.line) ?? fb.get(f.mark.line) ?? "" })),
2851
+ missed: missed.map((b) => ({ line: b.line, what: b.what })),
2852
+ falseAlarms: falseAlarms.map((m) => ({ line: m.line, feedback: fa.get(m.line) ?? "" })),
2853
+ score: Math.round(score * 100) / 100
2854
+ };
2855
+ return this.store.put(project.id, "hunts", h);
2856
+ }
2857
+ // ---- learning path -----------------------------------------------------------
2858
+ async path(project, req) {
2859
+ const ws = new Workspace(project);
2860
+ const all = ws.files();
2861
+ const rels = all.map((f) => ws.rel(f)).slice(0, 1500);
2862
+ const docs = all.filter((f) => /(^|\/)(readme|architecture|contributing|design)[^/]*\.md$/i.test(ws.rel(f))).slice(0, 3);
2863
+ const docText = docs.length ? ws.bundle(docs, 24e3) : "";
2864
+ const raw = await this.ask(
2865
+ ws,
2866
+ ["path"],
2867
+ pathSystem(),
2868
+ pathPrompt(rels, req.hubs.slice(0, 20).map((f) => ws.rel(f)), docText, req.goal?.trim()),
2869
+ { cache: true, plan: true }
2870
+ );
2871
+ const stops = arr(raw.stops).map((s) => {
2872
+ const o = s;
2873
+ const files = arr(o.files).map((f) => ws.absFromRel(str(f, 500))).filter((f) => !!f).slice(0, 4);
2874
+ const activity = ACTIVITIES.includes(o.activity) ? o.activity : "lens";
2875
+ return { title: str(o.title, 120), files, why: str(o.why, 800), activity };
2876
+ }).filter((s) => s.files.length && s.title).slice(0, 14);
2877
+ if (stops.length < 2) throw new Error("the model's path named no files that exist here");
2878
+ return this.store.put(project.id, "paths", { id: newId(), projectId: project.id, createdAt: now(), title: str(raw.title, 120) || "Onboarding path", stops });
2879
+ }
2880
+ // ---- progress --------------------------------------------------------
2881
+ progress(project) {
2882
+ return progress(this.store, project);
2883
+ }
2884
+ };
2885
+ async function inParallel(jobs, limit) {
2886
+ const out = new Array(jobs.length);
2887
+ let next = 0;
2888
+ const worker = async () => {
2889
+ while (next < jobs.length) {
2890
+ const i = next++;
2891
+ out[i] = await jobs[i]();
2892
+ }
2893
+ };
2894
+ await Promise.all(Array.from({ length: Math.min(limit, jobs.length) }, worker));
2895
+ return out;
2896
+ }
2897
+ function cleanDive(o, ws) {
2898
+ const file = ws.absFromRel(str(o.file, 500));
2899
+ const line2 = num(o.line);
2900
+ let startLine = num(o.startLine);
2901
+ let endLine = num(o.endLine);
2902
+ if (!startLine || !endLine || endLine < startLine) {
2903
+ startLine = line2 ? Math.max(1, line2 - 3) : void 0;
2904
+ endLine = line2 ? line2 + 6 : void 0;
2905
+ }
2906
+ if (startLine && endLine && endLine - startLine > 40) endLine = startLine + 40;
2907
+ const chk = o.check ?? {};
2908
+ const q = str(chk.q, 600);
2909
+ const a = str(chk.a, 1e3);
2910
+ return {
2911
+ title: str(o.title, 200),
2912
+ file,
2913
+ line: line2 ?? startLine,
2914
+ startLine,
2915
+ endLine,
2916
+ body: str(o.body, 12e3),
2917
+ pitfall: str(o.pitfall, 800) || void 0,
2918
+ check: q && a ? { q, a } : void 0
2919
+ };
2920
+ }
2921
+ function cleanTrace(v, ws) {
2922
+ const o = v ?? {};
2923
+ const steps = arr(o.steps).slice(0, 60).map((s) => {
2924
+ const x = s;
2925
+ const state = Object.fromEntries(
2926
+ Object.entries(x.state ?? {}).slice(0, 16).map(([k, val]) => [k.slice(0, 60), (typeof val === "string" ? val : JSON.stringify(val) ?? "").slice(0, 200)])
2927
+ );
2928
+ return { file: ws.absFromRel(str(x.file, 500)), line: num(x.line), state, note: str(x.note, 600) };
2929
+ });
2930
+ return steps.length ? { input: str(o.input, 600), steps } : void 0;
2931
+ }
2932
+ function plantBugs(original, raw) {
2933
+ const lines = original.split("\n");
2934
+ const planted = [];
2935
+ for (const b of raw) {
2936
+ const o = b;
2937
+ const want = str(o.original, 2e3).trim();
2938
+ const buggy = str(o.buggy, 2e3);
2939
+ if (!want || !buggy.trim() || buggy.trim() === want) continue;
2940
+ let at = (num(o.line) ?? 0) - 1;
2941
+ if (lines[at]?.trim() !== want) {
2942
+ const hits = lines.map((l, i) => l.trim() === want ? i : -1).filter((i) => i >= 0);
2943
+ if (hits.length !== 1) continue;
2944
+ at = hits[0];
2945
+ }
2946
+ if (planted.some((p) => p.line === at + 1)) continue;
2947
+ const indent = /^\s*/.exec(lines[at])?.[0] ?? "";
2948
+ planted.push({ line: at + 1, original: lines[at], buggy: indent + buggy.trimStart(), what: str(o.what, 800) });
2949
+ lines[at] = indent + buggy.trimStart();
2950
+ }
2951
+ return { text: lines.join("\n"), planted: planted.sort((a, b) => a.line - b.line) };
2952
+ }
2953
+ function matchMarks(planted, marks) {
2954
+ const left = [...planted];
2955
+ const found = [];
2956
+ const falseAlarms = [];
2957
+ for (const m of [...marks].sort((a, b) => a.line - b.line)) {
2958
+ const i = left.map((b) => Math.abs(b.line - m.line)).reduce((best, d, j, all) => d <= 1 && (best < 0 || d < all[best]) ? j : best, -1);
2959
+ if (i >= 0) found.push({ bug: left.splice(i, 1)[0], mark: m });
2960
+ else falseAlarms.push(m);
2961
+ }
2962
+ return { found, falseAlarms, missed: left };
2963
+ }
2964
+ function scoreOf(grades) {
2965
+ if (!grades.length) return 0;
2966
+ const pts = grades.reduce((s, g) => s + (g.verdict === "right" ? 1 : g.verdict === "partial" ? 0.5 : 0), 0);
2967
+ return Math.round(pts / grades.length * 100) / 100;
2968
+ }
2969
+ function normalizePractice(p) {
2970
+ if (p.files?.length) return p;
2971
+ return {
2972
+ ...p,
2973
+ files: [{ path: p.file, original: p.original, holed: p.holed, attempt: p.attempt }],
2974
+ holes: p.holes.map((h) => ({ ...h, file: h.file ?? p.file }))
2975
+ };
2976
+ }
2977
+ var TRIVIAL = /^\s*(?:$|\/\/|#|\*|\/\*|\*\/|[{}()\[\];,]+\s*$|import\b|from\s+\S+\s+import\b|export\s*\{|@\w+|"""|'\'\'\')/;
2978
+ function cleanHoles(raw, lineCount, lines) {
2979
+ const holes = raw.map((h) => h).map((o) => ({
2980
+ startLine: num(o.startLine) ?? 0,
2981
+ endLine: num(o.endLine) ?? num(o.startLine) ?? 0,
2982
+ intent: str(o.intent, 500),
2983
+ hints: arr(o.hints).map((x) => str(x, 500)).filter(Boolean).slice(0, 3)
2984
+ })).filter((h) => h.startLine >= 1 && h.endLine >= h.startLine && h.endLine <= lineCount).filter((h) => !lines || lines.slice(h.startLine - 1, h.endLine).filter((l) => !TRIVIAL.test(l)).length >= Math.min(2, h.endLine - h.startLine + 1)).sort((a, b) => a.startLine - b.startLine);
2985
+ const out = [];
2986
+ for (const h of holes) {
2987
+ const prev = out[out.length - 1];
2988
+ if (prev && h.startLine <= prev.endLine) continue;
2989
+ out.push({ id: out.length + 1, ...h });
2990
+ }
2991
+ return out;
2992
+ }
2993
+ var COMMENT = {
2994
+ python: "#",
2995
+ bash: "#",
2996
+ yaml: "#",
2997
+ toml: "#",
2998
+ ruby: "#",
2999
+ r: "#",
3000
+ elixir: "#",
3001
+ make: "#",
3002
+ docker: "#",
3003
+ hcl: "#",
3004
+ ini: ";",
3005
+ sql: "--",
3006
+ lua: "--",
3007
+ html: "<!--",
3008
+ xml: "<!--",
3009
+ markdown: "<!--",
3010
+ css: "/*",
3011
+ scss: "//",
3012
+ jinja: "{#"
3013
+ };
3014
+ var CLOSE = { "<!--": " -->", "/*": " */", "{#": " #}" };
3015
+ function applyHoles(original, holes, lang) {
3016
+ const lines = original.split("\n");
3017
+ const open = COMMENT[lang] ?? "//";
3018
+ const close = CLOSE[open] ?? "";
3019
+ for (const h of [...holes].sort((a, b) => b.startLine - a.startLine)) {
3020
+ const first = lines[h.startLine - 1] ?? "";
3021
+ const indent = /^\s*/.exec(first)?.[0] ?? "";
3022
+ const prev = lines.slice(0, h.startLine - 1).reverse().find((l) => l.trim());
3023
+ const next = lines.slice(h.endLine).find((l) => l.trim());
3024
+ const nextIndent = next === void 0 ? -1 : /^\s*/.exec(next)?.[0].length ?? 0;
3025
+ const emptyBlock = lang === "python" && !!prev && /:\s*(#.*)?$/.test(prev) && nextIndent < indent.length;
3026
+ lines.splice(h.startLine - 1, h.endLine - h.startLine + 1, `${indent}${emptyBlock ? "pass " : ""}${open} HOLE ${h.id}: ${h.intent}${close}`);
3027
+ }
3028
+ return lines.join("\n");
3029
+ }
3030
+
3031
+ // src/server/export.ts
3032
+ function lensMarkdown(l, ws) {
3033
+ const out = [`# ${l.title}`, "", `> ${l.question}`, "", `Covers: ${l.files.map((f) => ws.rel(f)).join(", ")}`, ""];
3034
+ if (l.tldr) out.push(`**TL;DR:** ${l.tldr}`, "");
3035
+ if (l.whyItMatters) out.push(`*Why it matters:* ${l.whyItMatters}`, "");
3036
+ if (l.summary) out.push(l.summary, "");
3037
+ if (l.sketch) {
3038
+ out.push("## Your take first", "", l.sketch, "");
3039
+ if (l.differences?.length) out.push("### Differences", "", ...l.differences.map((d) => `- ${d}`), "");
3040
+ }
3041
+ if (l.concepts.length) out.push("## Concepts", "", ...l.concepts.map((c) => `- **${c.term}**: ${c.explain}${c.example ? ` *Example:* ${c.example}` : ""}`), "");
3042
+ if (l.intuition) out.push("## Intuition", "", l.intuition, "");
3043
+ if (l.trace) {
3044
+ out.push("## Trace", "", `Input: \`${l.trace.input}\``, "", "| # | Line | State | Note |", "|---|---|---|---|");
3045
+ l.trace.steps.forEach((s, i) => {
3046
+ const state = Object.entries(s.state).map(([k, v]) => `${k}=${v}`).join(", ").replace(/\|/g, "\\|");
3047
+ out.push(`| ${i + 1} | ${s.file ? ws.rel(s.file) : ""}${s.line ? `:${s.line}` : ""} | ${state} | ${s.note.replace(/\|/g, "\\|")} |`);
3048
+ });
3049
+ out.push("");
3050
+ }
3051
+ if (l.deepDives.length) {
3052
+ out.push("## Deep dives", "");
3053
+ for (const d of l.deepDives) {
3054
+ const where = d.file ? ` (${ws.rel(d.file)}${d.startLine ? `:${d.startLine}-${d.endLine}` : d.line ? `:${d.line}` : ""})` : "";
3055
+ out.push(`### ${d.title}${where}`, "", d.body, "");
3056
+ if (d.pitfall) out.push(`> **Pitfall:** ${d.pitfall}`, "");
3057
+ if (d.check) out.push(`<details><summary>Check yourself: ${d.check.q}</summary>
3058
+
3059
+ ${d.check.a}
3060
+ </details>`, "");
3061
+ }
3062
+ }
3063
+ if (l.gotchas?.length) out.push("## Gotchas", "", ...l.gotchas.map((g) => `- ${g}`), "");
3064
+ if (l.nextSteps?.length) out.push("## Read next", "", ...l.nextSteps.map((n) => `- \`${ws.rel(n.file)}\`: ${n.why}`), "");
3065
+ return out.join("\n");
3066
+ }
3067
+ function quizMarkdown(q, ws) {
3068
+ const out = [`# ${q.title}`, "", `Covers: ${q.files.map((f) => ws.rel(f)).join(", ")}`, ""];
3069
+ const last = q.attempts[q.attempts.length - 1];
3070
+ if (last) out.push(`Result: ${Math.round(last.score * 100)}% on ${last.at.slice(0, 16).replace("T", " ")}`, "");
3071
+ q.questions.forEach((qq, i) => {
3072
+ out.push(`## Q${i + 1}. ${qq.prompt}`, "");
3073
+ const g = last?.grades.find((x) => x.qid === qq.id);
3074
+ const a = last?.answers.find((x) => x.qid === qq.id);
3075
+ if (a?.answer) out.push(`**Your answer** (${a.sure ? "sure" : "guess"}): ${a.answer}`, "");
3076
+ if (g) out.push(`**Verdict:** ${g.verdict}. ${g.feedback}`, "", `**A good answer:** ${g.modelAnswer}`, "");
3077
+ });
3078
+ return out.join("\n");
3079
+ }
3080
+
3081
+ // src/server/app.ts
3082
+ function createApi(store2, opts2 = {}) {
3083
+ const api = new Hono();
3084
+ const jobs = new Jobs(store2);
3085
+ const sweep = () => {
3086
+ try {
3087
+ prune(store2);
3088
+ } catch {
3089
+ }
3090
+ };
3091
+ sweep();
3092
+ if (opts2.pruneEveryMs) setInterval(sweep, opts2.pruneEveryMs).unref();
3093
+ const graphs = /* @__PURE__ */ new Map();
3094
+ const graphOf = (p) => {
3095
+ const hit = graphs.get(p.id);
3096
+ if (hit && Date.now() - hit.at < 6e4) return hit.g;
3097
+ const g = new Workspace(p).graph();
3098
+ graphs.set(p.id, { at: Date.now(), g });
3099
+ return g;
3100
+ };
3101
+ const hubs = (p) => graphOf(p).nodes.filter((n) => n.fanIn > 0).sort((a, b) => b.fanIn - a.fanIn).map((n) => n.path);
3102
+ const offline = () => new Learn(store2, { name: "none", complete: async () => "" });
3103
+ let current = null;
3104
+ const agent = () => current ??= makeAgent(store2.settings());
3105
+ const fail = (c, status, error) => c.json({ error }, status);
3106
+ const project = (c) => {
3107
+ const p = store2.project(c.req.param("pid") ?? "");
3108
+ if (!p) throw new HttpError(404, "project not found");
3109
+ return p;
3110
+ };
3111
+ const startJob = (c, kind, p, work) => {
3112
+ const a = agent();
3113
+ if (!a.agent) return fail(c, 503, a.info.detail);
3114
+ const learn = new Learn(store2, a.agent);
3115
+ return c.json(jobs.start(kind, p.id, () => work(learn)), 202);
3116
+ };
3117
+ const body = async (c) => {
3118
+ try {
3119
+ return await c.req.json();
3120
+ } catch {
3121
+ throw new HttpError(400, "body must be JSON");
3122
+ }
3123
+ };
3124
+ api.onError((err, c) => {
3125
+ if (err instanceof HttpError) return c.json({ error: err.message }, err.status);
3126
+ const msg = err instanceof Error ? err.message : String(err);
3127
+ const status = /outside the project|bad ref|not a file|pick at least|already graded|write a few|ask a question|write something|no open weak|needs a file inside a git repo|no commits yet/.test(msg) ? 400 : /ENOENT|not found/.test(msg) ? 404 : 500;
3128
+ return c.json({ error: msg }, status);
3129
+ });
3130
+ api.get("/health", (c) => c.json({ ok: true, home: store2.home, agent: agent().info }));
3131
+ api.get("/lens-kinds", (c) => c.json(LENS_KINDS));
3132
+ api.get("/settings", (c) => c.json({ settings: store2.settings(), agent: agent().info, home: store2.home }));
3133
+ api.put("/settings", async (c) => {
3134
+ const s = store2.saveSettings(await body(c));
3135
+ current = null;
3136
+ return c.json({ settings: s, agent: agent().info, home: store2.home });
3137
+ });
3138
+ api.get("/browse", (c) => {
3139
+ const path2 = c.req.query("path") || homedir2();
3140
+ if (!existsSync5(path2) || !statSync4(path2).isDirectory()) return fail(c, 404, "folder not found");
3141
+ return c.json(browse(path2));
3142
+ });
3143
+ api.get("/jobs", (c) => c.json(jobs.history()));
3144
+ api.get("/storage", (c) => c.json(usage(store2)));
3145
+ api.get("/storage/items", (c) => c.json(items(store2)));
3146
+ api.post("/storage/remove", async (c) => {
3147
+ const body2 = await c.req.json().catch(() => ({ ids: [] }));
3148
+ const ids = Array.isArray(body2.ids) ? body2.ids.filter((x) => typeof x === "string") : [];
3149
+ return c.json({ ...remove(store2, ids), usage: usage(store2) });
3150
+ });
3151
+ api.post("/storage/prune", (c) => c.json({ ...prune(store2), usage: usage(store2) }));
3152
+ api.get("/jobs/:id", (c) => {
3153
+ const j = jobs.get(c.req.param("id"));
3154
+ return j ? c.json(j) : fail(c, 404, "job not found");
3155
+ });
3156
+ api.get("/projects", (c) => c.json(store2.projects()));
3157
+ api.post("/projects", async (c) => {
3158
+ const { root, name } = await body(c);
3159
+ if (!root || !existsSync5(root) || !statSync4(root).isDirectory()) return fail(c, 400, "not a folder");
3160
+ return c.json(store2.openProject(root, name));
3161
+ });
3162
+ api.get("/projects/:pid", (c) => {
3163
+ const p = project(c);
3164
+ return c.json({ ...p, running: jobs.running(p.id) });
3165
+ });
3166
+ api.patch("/projects/:pid", async (c) => {
3167
+ const p = project(c);
3168
+ const patch = await body(c);
3169
+ for (const ctx of patch.contexts ?? []) {
3170
+ if (!existsSync5(ctx) || !statSync4(ctx).isDirectory()) return fail(c, 400, `not a folder: ${ctx}`);
3171
+ }
3172
+ return c.json(store2.updateProject(p.id, patch));
3173
+ });
3174
+ api.delete("/projects/:pid", (c) => {
3175
+ store2.forgetProject(project(c).id);
3176
+ return c.json({ ok: true });
3177
+ });
3178
+ api.get("/projects/:pid/tree", (c) => c.json(new Workspace(project(c)).tree()));
3179
+ api.get("/projects/:pid/file", (c) => c.json(new Workspace(project(c)).read(c.req.query("path") ?? "")));
3180
+ api.get("/projects/:pid/repos", (c) => c.json(new Workspace(project(c)).repos()));
3181
+ api.get("/projects/:pid/refs", (c) => c.json(new Workspace(project(c)).refs(c.req.query("repo") || void 0)));
3182
+ api.get("/projects/:pid/diff", (c) => {
3183
+ const spec = { repo: c.req.query("repo") || void 0, base: c.req.query("base") || "HEAD", head: c.req.query("head") ?? "" };
3184
+ return c.json(new Workspace(project(c)).diff(spec));
3185
+ });
3186
+ api.get("/projects/:pid/lenses", (c) => c.json(store2.list(project(c).id, "lenses")));
3187
+ api.get("/projects/:pid/lenses/:id", (c) => {
3188
+ const p = project(c);
3189
+ const l = store2.get(p.id, "lenses", c.req.param("id"));
3190
+ if (!l) return fail(c, 404, "lens not found");
3191
+ const ws = new Workspace(p);
3192
+ const changed = l.fileHashes ? l.files.filter((f) => ws.hash(f) !== l.fileHashes[f]) : [];
3193
+ return c.json({ ...l, changed });
3194
+ });
3195
+ api.post("/projects/:pid/lenses", async (c) => {
3196
+ const p = project(c);
3197
+ const req = await body(c);
3198
+ return startJob(c, "lens", p, (l) => l.lens(p, { ...req, files: req.files ?? [] }));
3199
+ });
3200
+ api.post("/projects/:pid/lenses/:id/refresh", (c) => {
3201
+ const p = project(c);
3202
+ const id = c.req.param("id");
3203
+ if (!store2.get(p.id, "lenses", id)) return fail(c, 404, "lens not found");
3204
+ return startJob(c, "lens", p, (l) => l.lens(p, { files: [], kind: "", refreshOf: id }));
3205
+ });
3206
+ api.get("/projects/:pid/lenses/:id/export", (c) => {
3207
+ const p = project(c);
3208
+ const l = store2.get(p.id, "lenses", c.req.param("id"));
3209
+ if (!l) return fail(c, 404, "lens not found");
3210
+ return download(c, `${slug(l.title)}.md`, lensMarkdown(l, new Workspace(p)));
3211
+ });
3212
+ api.delete("/projects/:pid/lenses/:id", (c) => c.json({ ok: store2.archive(project(c).id, "lenses", c.req.param("id")) }));
3213
+ api.get("/projects/:pid/quizzes", (c) => c.json(store2.list(project(c).id, "quizzes")));
3214
+ api.get("/projects/:pid/quizzes/:id", (c) => {
3215
+ const q = store2.get(project(c).id, "quizzes", c.req.param("id"));
3216
+ return q ? c.json(q) : fail(c, 404, "quiz not found");
3217
+ });
3218
+ api.post("/projects/:pid/quizzes", async (c) => {
3219
+ const p = project(c);
3220
+ const req = await body(c);
3221
+ return startJob(c, "quiz", p, (l) => l.quiz(p, { ...req, files: req.files ?? [] }));
3222
+ });
3223
+ api.post("/projects/:pid/quizzes/:id/attempts", async (c) => {
3224
+ const p = project(c);
3225
+ const { answers } = await body(c);
3226
+ const id = c.req.param("id");
3227
+ if (!store2.get(p.id, "quizzes", id)) return fail(c, 404, "quiz not found");
3228
+ return startJob(c, "grade", p, (l) => l.grade(p, id, answers ?? []));
3229
+ });
3230
+ api.delete("/projects/:pid/quizzes/:id", (c) => c.json({ ok: store2.archive(project(c).id, "quizzes", c.req.param("id")) }));
3231
+ api.get("/projects/:pid/quizzes/:id/export", (c) => {
3232
+ const p = project(c);
3233
+ const q = store2.get(p.id, "quizzes", c.req.param("id"));
3234
+ if (!q) return fail(c, 404, "quiz not found");
3235
+ return download(c, `${slug(q.title)}.md`, quizMarkdown(q, new Workspace(p)));
3236
+ });
3237
+ api.get("/projects/:pid/practice", (c) => c.json(store2.list(project(c).id, "practice")));
3238
+ api.get("/projects/:pid/practice/:id", (c) => {
3239
+ const x = store2.get(project(c).id, "practice", c.req.param("id"));
3240
+ return x ? c.json(normalizePractice(x)) : fail(c, 404, "practice not found");
3241
+ });
3242
+ api.post("/projects/:pid/practice", async (c) => {
3243
+ const p = project(c);
3244
+ const req = await body(c);
3245
+ return startJob(c, "practice", p, (l) => l.practice(p, req));
3246
+ });
3247
+ api.put("/projects/:pid/practice/:id/attempt", async (c) => {
3248
+ const p = project(c);
3249
+ const { text: text2, file } = await body(c);
3250
+ return c.json(offline().saveAttempt(p, c.req.param("id"), text2 ?? "", file));
3251
+ });
3252
+ api.post("/projects/:pid/practice/:id/review", (c) => {
3253
+ const p = project(c);
3254
+ const id = c.req.param("id");
3255
+ if (!store2.get(p.id, "practice", id)) return fail(c, 404, "practice not found");
3256
+ return startJob(c, "review", p, (l) => l.review(p, id));
3257
+ });
3258
+ api.delete("/projects/:pid/practice/:id", (c) => c.json({ ok: store2.archive(project(c).id, "practice", c.req.param("id")) }));
3259
+ api.get("/projects/:pid/progress", (c) => {
3260
+ const p = project(c);
3261
+ return c.json(offline().progress(p));
3262
+ });
3263
+ api.post("/projects/:pid/estimate", async (c) => {
3264
+ const { files } = await body(c);
3265
+ return c.json(offline().estimate(project(c), files ?? []));
3266
+ });
3267
+ const crud = (name, col, view = (x) => x) => {
3268
+ api.get(`/projects/:pid/${name}`, (c) => c.json(store2.list(project(c).id, col).map((x) => view(x))));
3269
+ api.get(`/projects/:pid/${name}/:id`, (c) => {
3270
+ const x = store2.get(project(c).id, col, c.req.param("id"));
3271
+ return x ? c.json(view(x)) : fail(c, 404, `${name} item not found`);
3272
+ });
3273
+ api.delete(`/projects/:pid/${name}/:id`, (c) => c.json({ ok: store2.archive(project(c).id, col, c.req.param("id")) }));
3274
+ };
3275
+ const withPreview = (card) => ({ ...card, next: previewCard(card) });
3276
+ api.get("/projects/:pid/cards/due", (c) => c.json(dueCards(store2, project(c).id).map(withPreview)));
3277
+ crud("cards", "cards");
3278
+ api.post("/projects/:pid/cards", async (c) => {
3279
+ const p = project(c);
3280
+ const b = await body(c);
3281
+ if (!b.front?.trim() || !b.back?.trim()) return fail(c, 400, "a card needs a front and a back");
3282
+ const ws = new Workspace(p);
3283
+ const file = b.file ? ws.guard(b.file) : void 0;
3284
+ const kind = b.kind === "concept" || b.kind === "miss" ? b.kind : "own";
3285
+ return c.json(store2.put(p.id, "cards", makeCard(p.id, { kind, front: b.front.trim(), back: b.back.trim(), file, line: b.line, source: b.source })));
3286
+ });
3287
+ api.post("/projects/:pid/cards/:id/review", async (c) => {
3288
+ const p = project(c);
3289
+ const card = store2.get(p.id, "cards", c.req.param("id"));
3290
+ if (!card) return fail(c, 404, "card not found");
3291
+ const { rating } = await body(c);
3292
+ if (!["again", "hard", "good", "easy"].includes(rating)) return fail(c, 400, "rating must be again, hard, good or easy");
3293
+ return c.json(store2.put(p.id, "cards", rateCard(card, rating)));
3294
+ });
3295
+ api.patch("/projects/:pid/cards/:id", async (c) => {
3296
+ const p = project(c);
3297
+ const card = store2.get(p.id, "cards", c.req.param("id"));
3298
+ if (!card) return fail(c, 404, "card not found");
3299
+ const b = await body(c);
3300
+ if (b.front?.trim()) card.front = b.front.trim().slice(0, 2e3);
3301
+ if (b.back?.trim()) card.back = b.back.trim().slice(0, 4e3);
3302
+ if (typeof b.suspended === "boolean") card.suspended = b.suspended;
3303
+ return c.json(store2.put(p.id, "cards", card));
3304
+ });
3305
+ api.get("/projects/:pid/misconceptions", (c) => {
3306
+ const p = project(c);
3307
+ return c.json(c.req.query("all") ? store2.list(p.id, "misconceptions") : openMisconceptions(store2, p.id));
3308
+ });
3309
+ api.post("/projects/:pid/misconceptions/:id/resolve", (c) => {
3310
+ const p = project(c);
3311
+ const m = store2.get(p.id, "misconceptions", c.req.param("id"));
3312
+ if (!m) return fail(c, 404, "not found");
3313
+ m.resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
3314
+ return c.json(store2.put(p.id, "misconceptions", m));
3315
+ });
3316
+ api.delete("/projects/:pid/misconceptions/:id", (c) => c.json({ ok: store2.archive(project(c).id, "misconceptions", c.req.param("id")) }));
3317
+ crud("teachbacks", "teachbacks");
3318
+ api.post("/projects/:pid/teachbacks", async (c) => {
3319
+ const p = project(c);
3320
+ const req = await body(c);
3321
+ return startJob(c, "teachback", p, (l) => l.teachback(p, { files: req.files ?? [], text: req.text ?? "" }));
3322
+ });
3323
+ crud("threads", "threads");
3324
+ api.post("/projects/:pid/threads", async (c) => {
3325
+ const p = project(c);
3326
+ const req = await body(c);
3327
+ return startJob(c, "tutor", p, (l) => l.startThread(p, { files: req.files ?? [], text: req.text ?? "" }));
3328
+ });
3329
+ api.post("/projects/:pid/threads/:id/messages", async (c) => {
3330
+ const p = project(c);
3331
+ const id = c.req.param("id");
3332
+ if (!store2.get(p.id, "threads", id)) return fail(c, 404, "thread not found");
3333
+ const req = await body(c);
3334
+ return startJob(c, "tutor", p, (l) => l.tutorReply(p, id, req.text ?? "", !!req.tell));
3335
+ });
3336
+ crud("walks", "walks");
3337
+ api.post("/projects/:pid/walks", async (c) => {
3338
+ const p = project(c);
3339
+ const req = await body(c);
3340
+ return startJob(c, "walk", p, (l) => l.walk(p, req));
3341
+ });
3342
+ api.post("/projects/:pid/walks/:id/blocks/:i", async (c) => {
3343
+ const p = project(c);
3344
+ const id = c.req.param("id");
3345
+ if (!store2.get(p.id, "walks", id)) return fail(c, 404, "walkthrough not found");
3346
+ const { answer } = await body(c);
3347
+ return startJob(c, "walkcheck", p, (l) => l.walkcheck(p, id, Number(c.req.param("i")), answer ?? ""));
3348
+ });
3349
+ const huntView = (h) => h.grade ? h : { ...h, planted: h.planted.map(() => null) };
3350
+ crud("hunts", "hunts", huntView);
3351
+ api.post("/projects/:pid/hunts", async (c) => {
3352
+ const p = project(c);
3353
+ const req = await body(c);
3354
+ return startJob(c, "hunt", p, (l) => l.hunt(p, req));
3355
+ });
3356
+ api.put("/projects/:pid/hunts/:id/marks", async (c) => {
3357
+ const p = project(c);
3358
+ const { marks } = await body(c);
3359
+ return c.json(huntView(offline().saveMarks(p, c.req.param("id"), marks ?? [])));
3360
+ });
3361
+ api.post("/projects/:pid/hunts/:id/grade", (c) => {
3362
+ const p = project(c);
3363
+ const id = c.req.param("id");
3364
+ if (!store2.get(p.id, "hunts", id)) return fail(c, 404, "bug hunt not found");
3365
+ return startJob(c, "huntgrade", p, (l) => l.huntgrade(p, id));
3366
+ });
3367
+ api.get("/projects/:pid/graph", (c) => c.json(graphOf(project(c))));
3368
+ api.get("/projects/:pid/glossary", (c) => c.json(glossary(store2, project(c).id)));
3369
+ const pathView = (x) => ({ ...x, done: pathDone(store2, x) });
3370
+ crud("paths", "paths", pathView);
3371
+ api.post("/projects/:pid/paths", async (c) => {
3372
+ const p = project(c);
3373
+ const { goal } = await body(c);
3374
+ return startJob(c, "path", p, (l) => l.path(p, { goal, hubs: hubs(p) }));
3375
+ });
3376
+ api.patch("/projects/:pid/paths/:id", async (c) => {
3377
+ const p = project(c);
3378
+ const x = store2.get(p.id, "paths", c.req.param("id"));
3379
+ if (!x) return fail(c, 404, "path not found");
3380
+ const b = await body(c);
3381
+ if (b.title?.trim()) x.title = b.title.trim().slice(0, 120);
3382
+ if (Array.isArray(b.stops)) {
3383
+ const ws = new Workspace(p);
3384
+ x.stops = b.stops.filter((s) => s && Array.isArray(s.files)).map((s) => ({ ...s, files: s.files.map((f) => ws.guard(f)) }));
3385
+ }
3386
+ return c.json(pathView(store2.put(p.id, "paths", x)));
3387
+ });
3388
+ api.get("/projects/:pid/notes", (c) => {
3389
+ const file = c.req.query("file");
3390
+ const list = store2.list(project(c).id, "notes");
3391
+ return c.json(file ? list.filter((n) => n.file === file) : list);
3392
+ });
3393
+ api.post("/projects/:pid/notes", async (c) => {
3394
+ const p = project(c);
3395
+ const b = await body(c);
3396
+ if (!b.text?.trim()) return fail(c, 400, "a note needs text");
3397
+ const note = {
3398
+ id: newId(),
3399
+ projectId: p.id,
3400
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3401
+ text: b.text.trim().slice(0, 8e3),
3402
+ file: b.file ? new Workspace(p).guard(b.file) : void 0,
3403
+ line: b.line,
3404
+ lensId: b.lensId
3405
+ };
3406
+ return c.json(store2.put(p.id, "notes", note));
3407
+ });
3408
+ api.patch("/projects/:pid/notes/:id", async (c) => {
3409
+ const p = project(c);
3410
+ const n = store2.get(p.id, "notes", c.req.param("id"));
3411
+ if (!n) return fail(c, 404, "note not found");
3412
+ const b = await body(c);
3413
+ if (b.text?.trim()) n.text = b.text.trim().slice(0, 8e3);
3414
+ return c.json(store2.put(p.id, "notes", n));
3415
+ });
3416
+ api.delete("/projects/:pid/notes/:id", (c) => c.json({ ok: store2.archive(project(c).id, "notes", c.req.param("id")) }));
3417
+ api.get("/projects/:pid/today", (c) => {
3418
+ const p = project(c);
3419
+ return c.json(today(store2, p, () => hubs(p)));
3420
+ });
3421
+ api.get("/projects/:pid/export", (c) => {
3422
+ const p = project(c);
3423
+ const all = { project: p, exportedAt: (/* @__PURE__ */ new Date()).toISOString() };
3424
+ for (const col of ["lenses", "quizzes", "practice", "cards", "misconceptions", "teachbacks", "threads", "walks", "hunts", "paths", "notes"]) {
3425
+ all[col] = store2.list(p.id, col);
3426
+ }
3427
+ return download(c, `learnforge-${p.id}.json`, JSON.stringify(all, null, 2), "application/json");
3428
+ });
3429
+ return api;
3430
+ }
3431
+ var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "export";
3432
+ function download(c, name, text2, type = "text/markdown; charset=utf-8") {
3433
+ return c.body(text2, 200, { "content-type": type, "content-disposition": `attachment; filename="${name}"` });
3434
+ }
3435
+ var HttpError = class extends Error {
3436
+ constructor(status, message) {
3437
+ super(message);
3438
+ this.status = status;
3439
+ }
3440
+ };
3441
+
3442
+ // src/cli.ts
3443
+ var HELP = `learnforge - understand any codebase
3444
+
3445
+ Usage: learnforge [folder] [options]
3446
+
3447
+ folder Folder to open (default: show the project list)
3448
+ --port <n> Port (default 4711, or LEARNFORGE_PORT)
3449
+ --host <addr> Address to bind (default 127.0.0.1)
3450
+ --no-open Do not open the browser
3451
+ --demo Try it with a small sample repo and canned answers (no model, temp data)
3452
+ -h, --help Show this help
3453
+
3454
+ Data lives in ~/.learnforge (LEARNFORGE_HOME). Your folders are never written. Old archive
3455
+ and cache entries are cleaned up on their own (Settings > Storage).
3456
+ Model: the claude, codex or copilot CLI if installed (their own login), else ANTHROPIC_API_KEY.`;
3457
+ function parseArgs(argv) {
3458
+ const out = { folder: "", port: Number(process.env.LEARNFORGE_PORT) || 4711, host: "127.0.0.1", open: true, demo: false };
3459
+ for (let i = 0; i < argv.length; i++) {
3460
+ const a = argv[i];
3461
+ if (a === "-h" || a === "--help") {
3462
+ console.log(HELP);
3463
+ process.exit(0);
3464
+ } else if (a === "--port") out.port = Number(argv[++i]);
3465
+ else if (a === "--host") out.host = argv[++i];
3466
+ else if (a === "--no-open") out.open = false;
3467
+ else if (a === "--demo") out.demo = true;
3468
+ else if (!a.startsWith("-")) out.folder = a;
3469
+ else {
3470
+ console.error(`unknown option: ${a}
3471
+
3472
+ ${HELP}`);
3473
+ process.exit(2);
3474
+ }
3475
+ }
3476
+ return out;
3477
+ }
3478
+ var MIME = {
3479
+ ".html": "text/html; charset=utf-8",
3480
+ ".js": "text/javascript",
3481
+ ".css": "text/css",
3482
+ ".svg": "image/svg+xml",
3483
+ ".png": "image/png",
3484
+ ".woff2": "font/woff2",
3485
+ ".woff": "font/woff",
3486
+ ".json": "application/json",
3487
+ ".ico": "image/x-icon",
3488
+ ".wasm": "application/wasm"
3489
+ };
3490
+ function webDir() {
3491
+ const here = dirname3(fileURLToPath(import.meta.url));
3492
+ for (const d of [join8(here, "web"), join8(here, "..", "dist", "web")]) if (existsSync6(join8(d, "index.html"))) return d;
3493
+ return null;
3494
+ }
3495
+ function openBrowser(url) {
3496
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
3497
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
3498
+ try {
3499
+ spawn2(cmd, args, { stdio: "ignore", detached: true }).on("error", () => {
3500
+ }).unref();
3501
+ } catch {
3502
+ }
3503
+ }
3504
+ function demoDir() {
3505
+ const here = dirname3(fileURLToPath(import.meta.url));
3506
+ for (const d of [join8(here, "..", "demo", "sample"), join8(here, "demo", "sample")]) if (existsSync6(d)) return d;
3507
+ return null;
3508
+ }
3509
+ var opts = parseArgs(process.argv.slice(2));
3510
+ if (opts.demo) {
3511
+ const sample = demoDir();
3512
+ if (!sample) {
3513
+ console.error("demo sample not found in this install");
3514
+ process.exit(2);
3515
+ }
3516
+ process.env.LEARNFORGE_AGENT = "fake";
3517
+ process.env.LEARNFORGE_HOME = join8(tmpdir3(), "learnforge-demo");
3518
+ process.env.LEARNFORGE_FAKE_DELAY_MS ??= "1500";
3519
+ opts.folder = sample;
3520
+ }
3521
+ var store = new Store();
3522
+ var app = new Hono2();
3523
+ app.route("/api", createApi(store, { pruneEveryMs: 6 * 36e5 }));
3524
+ var web = webDir();
3525
+ app.get("*", (c) => {
3526
+ if (!web) return c.text("Web app not built. Run `npm run build`, or `npm run dev` and open http://localhost:5173.", 503);
3527
+ const rel = decodeURIComponent(new URL(c.req.url).pathname).replace(/^\/+/, "");
3528
+ const file = resolve4(web, rel);
3529
+ const target = file.startsWith(web) && existsSync6(file) && statSync5(file).isFile() ? file : join8(web, "index.html");
3530
+ const type = MIME[extname3(target)] ?? "application/octet-stream";
3531
+ const cache = target.includes(`${join8(web, "assets")}`) ? "public, max-age=31536000, immutable" : "no-cache";
3532
+ return c.body(readFileSync5(target), 200, { "content-type": type, "cache-control": cache });
3533
+ });
3534
+ var path = "/";
3535
+ if (opts.folder) {
3536
+ const root = resolve4(opts.folder);
3537
+ if (!existsSync6(root) || !statSync5(root).isDirectory()) {
3538
+ console.error(`not a folder: ${root}`);
3539
+ process.exit(2);
3540
+ }
3541
+ path = `/p/${store.openProject(root).id}/today`;
3542
+ }
3543
+ serve({ fetch: app.fetch, port: opts.port, hostname: opts.host }, (info) => {
3544
+ const url = `http://${opts.host === "0.0.0.0" ? "localhost" : opts.host}:${info.port}${path}`;
3545
+ console.log(`learnforge running at ${url}
3546
+ data: ${store.home}
3547
+ Ctrl+C to stop.`);
3548
+ if (opts.open && web) openBrowser(url);
3549
+ });