ronin-skillforce 0.0.0-stage → 0.1.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.
- package/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +225 -2
- package/bin/cli.js +811 -0
- package/bin/commands/check.js +298 -0
- package/bin/commands/create.js +169 -0
- package/bin/commands/doctor.js +392 -0
- package/bin/commands/import.js +557 -0
- package/bin/commands/next.js +125 -0
- package/bin/commands/review.js +207 -0
- package/bin/commands/status.js +114 -0
- package/bin/commands/studio.js +497 -0
- package/bin/commands/trail.js +211 -0
- package/bin/commands/web.js +350 -0
- package/bin/course-reader.js +611 -0
- package/bin/node-version.js +60 -0
- package/bin/platforms/_shared.js +211 -0
- package/bin/platforms/claude.js +104 -0
- package/bin/platforms/cursor.js +49 -0
- package/bin/platforms/gemini.js +65 -0
- package/bin/platforms/opencode.js +95 -0
- package/bin/registry-helpers.js +504 -0
- package/bin/static-server.js +153 -0
- package/bin/studio-store.js +94 -0
- package/bin/sync-shared.sh +5 -0
- package/package.json +91 -4
- package/plugin.json +139 -0
- package/shared/SKILL.md +568 -0
- package/shared/agents/coach.md +225 -0
- package/shared/agents/navigator.md +215 -0
- package/shared/agents/professor.md +372 -0
- package/shared/agents/researcher.md +181 -0
- package/shared/agents/spotter.md +129 -0
- package/shared/commands/professor/archive.md +103 -0
- package/shared/commands/professor/capstone-review.md +79 -0
- package/shared/commands/professor/capstone.md +34 -0
- package/shared/commands/professor/discuss.md +18 -0
- package/shared/commands/professor/done.md +44 -0
- package/shared/commands/professor/export.md +69 -0
- package/shared/commands/professor/hint.md +108 -0
- package/shared/commands/professor/navigator.md +67 -0
- package/shared/commands/professor/new-topic.md +73 -0
- package/shared/commands/professor/next.md +174 -0
- package/shared/commands/professor/note.md +63 -0
- package/shared/commands/professor/onboard.md +95 -0
- package/shared/commands/professor/progress.md +46 -0
- package/shared/commands/professor/publish.md +93 -0
- package/shared/commands/professor/quiz.md +27 -0
- package/shared/commands/professor/recall.md +74 -0
- package/shared/commands/professor/review.md +43 -0
- package/shared/commands/professor/schedule.md +75 -0
- package/shared/commands/professor/skill-export.md +87 -0
- package/shared/commands/professor/spotter.md +76 -0
- package/shared/commands/professor/stuck.md +27 -0
- package/shared/commands/professor/switch.md +50 -0
- package/shared/commands/professor/syllabus.md +9 -0
- package/shared/commands/professor/template-export.md +91 -0
- package/shared/commands/professor/template-import.md +111 -0
- package/shared/commands/professor/worktrees.md +58 -0
- package/shared/hooks/pre-compact.js +232 -0
- package/studio/README.md +313 -0
- package/studio/dist/assets/BetaReportPanel-8NrDLiPm.js +1 -0
- package/studio/dist/assets/Capstone-Cv9kH6LY.js +6 -0
- package/studio/dist/assets/CodeWorkspace-BwCEINWo.js +2 -0
- package/studio/dist/assets/Course-BJ_hafUP.js +1 -0
- package/studio/dist/assets/Editor-CV0mNhlz.js +53 -0
- package/studio/dist/assets/Home-C1ry89ak.js +1 -0
- package/studio/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/studio/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/studio/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/studio/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/studio/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/studio/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/studio/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/studio/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/studio/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/studio/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/studio/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/studio/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/studio/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/studio/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/studio/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/studio/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/studio/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/studio/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/studio/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/studio/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/studio/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/studio/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/studio/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/studio/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/studio/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/studio/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/studio/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/studio/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/studio/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/studio/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/studio/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/studio/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/studio/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/studio/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/studio/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/studio/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/studio/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/studio/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/studio/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/studio/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/studio/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/studio/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/studio/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/studio/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/studio/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/studio/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/studio/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/studio/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/studio/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/studio/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/studio/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/studio/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/studio/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/studio/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/studio/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/studio/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/studio/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/studio/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/studio/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/studio/dist/assets/Lesson-2e9TD9yL.js +2 -0
- package/studio/dist/assets/NewCourse-BZeOg2xn.js +952 -0
- package/studio/dist/assets/Progress-CRc_xEWC.js +1 -0
- package/studio/dist/assets/Review-6QEbHEfk.js +1 -0
- package/studio/dist/assets/Settings-NFnqVQui.js +1 -0
- package/studio/dist/assets/TutorPanel-DXQ6Z3BY.js +123 -0
- package/studio/dist/assets/abnfDiagram-WEYOIA3C-qeSjKlGM.js +1 -0
- package/studio/dist/assets/anthropic-D7NiQCUH.js +15 -0
- package/studio/dist/assets/arc-Y1ARJVZ4.js +1 -0
- package/studio/dist/assets/architecture-WOLXFQ4H-COG-m35l.js +1 -0
- package/studio/dist/assets/architectureDiagram-ZHVYKT53-VO7uKoeD.js +36 -0
- package/studio/dist/assets/array-BifhSqXX.js +1 -0
- package/studio/dist/assets/blockDiagram-RJAE7QA3-BGJF_qiL.js +140 -0
- package/studio/dist/assets/bridge-BTh0AeNX.js +1 -0
- package/studio/dist/assets/c4Diagram-XDPPPE54-C-S10RgU.js +38 -0
- package/studio/dist/assets/capstone-Amn6Hztq.js +135 -0
- package/studio/dist/assets/card-store-BrbrtX9w.js +1 -0
- package/studio/dist/assets/channel-SJiUQu_N.js +1 -0
- package/studio/dist/assets/chunk-2E7WHVMD-fv-iqTyC.js +88 -0
- package/studio/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/studio/dist/assets/chunk-4EMNKR2T-CY9IZrgv.js +1 -0
- package/studio/dist/assets/chunk-55XCU76K-DyTZz7v_.js +1 -0
- package/studio/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/studio/dist/assets/chunk-7S6XSTFO-BNMzTLRW.js +2 -0
- package/studio/dist/assets/chunk-AQDTSKZB-CEYANohH.js +72 -0
- package/studio/dist/assets/chunk-DSNLSHJZ-CDk5EXjH.js +129 -0
- package/studio/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/studio/dist/assets/chunk-HAGFOTED-hNAi1zh_.js +62 -0
- package/studio/dist/assets/chunk-HNE4Q36P-COJnSYoZ.js +1 -0
- package/studio/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/studio/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/studio/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/studio/dist/assets/chunk-NQOKRVIF-BU6KVxRr.js +213 -0
- package/studio/dist/assets/chunk-UHW7PFBP-DYvxL9JG.js +1 -0
- package/studio/dist/assets/chunk-VQ5W62QI-CuRfk65j.js +1 -0
- package/studio/dist/assets/chunk-WQQL4CDK-KaG7x-Mb.js +10 -0
- package/studio/dist/assets/chunk-XXDRQBXY-CnbRkih_.js +1 -0
- package/studio/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/studio/dist/assets/chunk-YZO67HFG-DbuqIZmk.js +1 -0
- package/studio/dist/assets/chunk-ZAUX2LTX-bk4ivUmc.js +1 -0
- package/studio/dist/assets/chunk-ZCHMQSOR-B_LElfwh.js +2 -0
- package/studio/dist/assets/classDiagram-v2-4MYWR3UA-DtCJ6H_X.js +217 -0
- package/studio/dist/assets/cose-bilkent-JH36ORCC-DMBAZFnj.js +1 -0
- package/studio/dist/assets/course-export-DpaOTT3a.js +1 -0
- package/studio/dist/assets/cynefin-EF2NZ3EQ-D9btHJvU.js +1 -0
- package/studio/dist/assets/cynefinDiagram-WF24UDRP-CsFJuv27.js +62 -0
- package/studio/dist/assets/cytoscape.esm-CHBj4VkZ.js +321 -0
- package/studio/dist/assets/dagre-NK4GIZVX-Cg1Duec7.js +4 -0
- package/studio/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/studio/dist/assets/diagram-CBK3MWTC-_KU2m5gd.js +24 -0
- package/studio/dist/assets/diagram-I5XIIV2D-5RaBrwUA.js +24 -0
- package/studio/dist/assets/diagram-ODIL2UCW-C0VKxoD3.js +30 -0
- package/studio/dist/assets/diagram-PFBEJUXE-BaHcZEP2.js +3 -0
- package/studio/dist/assets/diagram-RJA3I4VV-cwSAZ90p.js +41 -0
- package/studio/dist/assets/diagram-TYUNFSNR-BJImxCEN.js +200 -0
- package/studio/dist/assets/dist-CumnGVmJ.js +1 -0
- package/studio/dist/assets/ebnfDiagram-V4P3TTPT-OQQLRXcm.js +1 -0
- package/studio/dist/assets/elk-QONTBCTQ-BJGhQHxQ.js +27 -0
- package/studio/dist/assets/erDiagram-KYQIGQAA-D91EKSep.js +99 -0
- package/studio/dist/assets/error-BTdmRKoB.js +1 -0
- package/studio/dist/assets/errors-BZa_CNHL.js +1 -0
- package/studio/dist/assets/eventmodeling-K75KTNOO-DgdTRgnw.js +1 -0
- package/studio/dist/assets/fetch-local-DG3O3hmQ.js +4 -0
- package/studio/dist/assets/flowDiagram-IUGLLWBU-CVnJYMum.js +1 -0
- package/studio/dist/assets/ganttDiagram-R7GPNTJY-CSYLPWEI.js +292 -0
- package/studio/dist/assets/gitGraph-VSP46ZUC-D6GWaV9y.js +1 -0
- package/studio/dist/assets/gitGraphDiagram-PDYL7XHY-CaV31bvT.js +106 -0
- package/studio/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/studio/dist/assets/index-CVenTTOY.css +2 -0
- package/studio/dist/assets/index-DioXTZVc.js +313 -0
- package/studio/dist/assets/info-OHQRW6UA-BN-x8GoN.js +1 -0
- package/studio/dist/assets/infoDiagram-MVDRKAX5-CIose_cy.js +2 -0
- package/studio/dist/assets/init-C-OQMol4.js +1 -0
- package/studio/dist/assets/ishikawaDiagram-HPEHV2GE-DCihSNBw.js +70 -0
- package/studio/dist/assets/journeyDiagram-BOSHW7O7-Bz3o3RIX.js +139 -0
- package/studio/dist/assets/js.worker-AD7DjksO.js +4 -0
- package/studio/dist/assets/kanban-definition-FNSA4HC5-BUyqvlFE.js +89 -0
- package/studio/dist/assets/katex-B_JcIwqj.js +257 -0
- package/studio/dist/assets/katex-BgsA2lO1.css +1 -0
- package/studio/dist/assets/line-B08T8KlK.js +1 -0
- package/studio/dist/assets/linear-C9iwaMOi.js +1 -0
- package/studio/dist/assets/mermaid-parser.core-BYwreSUy.js +7 -0
- package/studio/dist/assets/mermaid.core-DTO1oDmq.js +44 -0
- package/studio/dist/assets/mindmap-definition-6OY57UG3-BGLwKheY.js +98 -0
- package/studio/dist/assets/node.browser-BISniYn8.js +1 -0
- package/studio/dist/assets/node.browser-HQ5SKBOf.js +1 -0
- package/studio/dist/assets/ollama-De5w84tG.js +1 -0
- package/studio/dist/assets/openai-compatible-Joy1oK3L.js +1 -0
- package/studio/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/studio/dist/assets/packet-JDAUHWVQ-CDIMjHS_.js +1 -0
- package/studio/dist/assets/path-fybaL0A-.js +1 -0
- package/studio/dist/assets/pegDiagram-ECIUJNBX-DWXm4mwS.js +1 -0
- package/studio/dist/assets/pie-XZMESJXO-CY3QQiKZ.js +1 -0
- package/studio/dist/assets/pieDiagram-GNMPY74T-M1aCnI7W.js +39 -0
- package/studio/dist/assets/python.worker-CzTrFdd9.js +24 -0
- package/studio/dist/assets/quadrantDiagram-D57K4MC3-BFFtO7U5.js +7 -0
- package/studio/dist/assets/radar-ABXABTNO-DT0W4_Xn.js +1 -0
- package/studio/dist/assets/railroad-I3PHUGI6-_VCpgZR5.js +1 -0
- package/studio/dist/assets/railroad-abnf-LNEFI6M7-DkSpQ5YY.js +1 -0
- package/studio/dist/assets/railroad-ebnf-DWYD2UWJ-BqWILdGb.js +1 -0
- package/studio/dist/assets/railroad-peg-JR7BVNR7-BB0bQL-Z.js +1 -0
- package/studio/dist/assets/railroadDiagram-Y72NRBL7-CjLKjIdJ.js +1 -0
- package/studio/dist/assets/react-B8SKf8RM.js +1 -0
- package/studio/dist/assets/rehype-katex-C-sKddsH.js +259 -0
- package/studio/dist/assets/remark-math-C-ox67hE.js +5 -0
- package/studio/dist/assets/requirementDiagram-EQYSW5RA-CnwzZqR8.js +84 -0
- package/studio/dist/assets/rolldown-runtime-COiEfhsA.js +1 -0
- package/studio/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/studio/dist/assets/sankeyDiagram-SY24JHO5-CPDugTUl.js +40 -0
- package/studio/dist/assets/schemas-BOc2YsOP.js +55 -0
- package/studio/dist/assets/sequenceDiagram-BLEHNHQU-1dLfeU9Y.js +169 -0
- package/studio/dist/assets/settings-DhwuyNPU.js +1 -0
- package/studio/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/studio/dist/assets/spec-nAnsSMkw.js +1 -0
- package/studio/dist/assets/sql-wasm-C1D_RJuy.wasm +0 -0
- package/studio/dist/assets/sql.worker-D7pBHOrq.js +4 -0
- package/studio/dist/assets/src-BWF7jMDv.js +1 -0
- package/studio/dist/assets/stateDiagram-v2-A6IESKWZ-DR7CmEjj.js +291 -0
- package/studio/dist/assets/swimlanes-B3ENRKFQ-BAWB9omQ.js +1 -0
- package/studio/dist/assets/swimlanesDiagram-AYQRV4AK-qeuHru16.js +8 -0
- package/studio/dist/assets/timeline-definition-JXYKGLGB-DNkj8z77.js +120 -0
- package/studio/dist/assets/treeView-D4JQ5SDB-BA-ouhMb.js +1 -0
- package/studio/dist/assets/treemap-SAJKECNS-BrrGxsAb.js +1 -0
- package/studio/dist/assets/useStream-rdsR6A7P.js +1 -0
- package/studio/dist/assets/usecaseDiagram-WPOLBNNX-BB3D0Dt_.js +328 -0
- package/studio/dist/assets/vennDiagram-Q3Y3IDCW-BrtqtKTE.js +34 -0
- package/studio/dist/assets/wardley-7MLQ67FV-BKkemDql.js +1 -0
- package/studio/dist/assets/wardleyDiagram-IVAIDKWA-DFnI3P5z.js +78 -0
- package/studio/dist/assets/xychartDiagram-4URNHDU4-Da0DZG_q.js +7 -0
- package/studio/dist/index.html +20 -0
- package/studio/dist/preview.html +22 -0
- package/studio/migrations/0001_init.sql +188 -0
- package/studio/migrations/0002_external_ids.sql +20 -0
- package/studio/migrations/0003_course_language.sql +12 -0
- package/studio/migrations/0004_capstone.sql +51 -0
- package/studio/migrations/0005_beta_loop.sql +73 -0
- package/studio/migrations/0006_interactive_exercises.sql +49 -0
- package/studio/src/lib/activity.js +92 -0
- package/studio/src/lib/ai/anthropic.js +109 -0
- package/studio/src/lib/ai/bridge.js +126 -0
- package/studio/src/lib/ai/catalog.js +160 -0
- package/studio/src/lib/ai/config.js +70 -0
- package/studio/src/lib/ai/errors.js +95 -0
- package/studio/src/lib/ai/fetch-local.js +104 -0
- package/studio/src/lib/ai/index.js +194 -0
- package/studio/src/lib/ai/ollama.js +136 -0
- package/studio/src/lib/ai/openai-compatible.js +101 -0
- package/studio/src/lib/ai/provider-utils.js +107 -0
- package/studio/src/lib/ai/sse.js +99 -0
- package/studio/src/lib/ai/status.js +92 -0
- package/studio/src/lib/ai/useStream.js +109 -0
- package/studio/src/lib/beta/metrics.js +147 -0
- package/studio/src/lib/beta/report.js +134 -0
- package/studio/src/lib/capstone-evidence.js +758 -0
- package/studio/src/lib/capstone.js +663 -0
- package/studio/src/lib/chat-store.js +128 -0
- package/studio/src/lib/course-export.js +38 -0
- package/studio/src/lib/course.js +187 -0
- package/studio/src/lib/exercises/journal-entry.js +259 -0
- package/studio/src/lib/exercises/placement.js +31 -0
- package/studio/src/lib/exercises/registry.js +19 -0
- package/studio/src/lib/exercises/spec.js +129 -0
- package/studio/src/lib/i18n/detect.js +162 -0
- package/studio/src/lib/i18n/en/app.json +23 -0
- package/studio/src/lib/i18n/en/betaReport.json +55 -0
- package/studio/src/lib/i18n/en/capstone.json +57 -0
- package/studio/src/lib/i18n/en/common.json +25 -0
- package/studio/src/lib/i18n/en/course.json +27 -0
- package/studio/src/lib/i18n/en/data.json +35 -0
- package/studio/src/lib/i18n/en/durability.json +5 -0
- package/studio/src/lib/i18n/en/errors.json +20 -0
- package/studio/src/lib/i18n/en/exercises.json +55 -0
- package/studio/src/lib/i18n/en/externalEvidence.json +76 -0
- package/studio/src/lib/i18n/en/home.json +18 -0
- package/studio/src/lib/i18n/en/lesson.json +33 -0
- package/studio/src/lib/i18n/en/nav.json +8 -0
- package/studio/src/lib/i18n/en/newCourse.json +51 -0
- package/studio/src/lib/i18n/en/onboarding.json +35 -0
- package/studio/src/lib/i18n/en/playwrightTemplate.json +20 -0
- package/studio/src/lib/i18n/en/practice.json +79 -0
- package/studio/src/lib/i18n/en/provider.json +27 -0
- package/studio/src/lib/i18n/en/providerMeta.json +26 -0
- package/studio/src/lib/i18n/en/reflection.json +17 -0
- package/studio/src/lib/i18n/en/revision.json +52 -0
- package/studio/src/lib/i18n/en/runnerNotice.json +14 -0
- package/studio/src/lib/i18n/en/settings.json +12 -0
- package/studio/src/lib/i18n/en/storage.json +49 -0
- package/studio/src/lib/i18n/en/tutor.json +39 -0
- package/studio/src/lib/i18n/en/workspace.json +50 -0
- package/studio/src/lib/i18n/index.js +144 -0
- package/studio/src/lib/i18n/languages.js +93 -0
- package/studio/src/lib/i18n/vi/app.json +23 -0
- package/studio/src/lib/i18n/vi/betaReport.json +55 -0
- package/studio/src/lib/i18n/vi/capstone.json +57 -0
- package/studio/src/lib/i18n/vi/common.json +25 -0
- package/studio/src/lib/i18n/vi/course.json +27 -0
- package/studio/src/lib/i18n/vi/data.json +35 -0
- package/studio/src/lib/i18n/vi/durability.json +5 -0
- package/studio/src/lib/i18n/vi/errors.json +20 -0
- package/studio/src/lib/i18n/vi/exercises.json +55 -0
- package/studio/src/lib/i18n/vi/externalEvidence.json +76 -0
- package/studio/src/lib/i18n/vi/home.json +18 -0
- package/studio/src/lib/i18n/vi/lesson.json +33 -0
- package/studio/src/lib/i18n/vi/nav.json +8 -0
- package/studio/src/lib/i18n/vi/newCourse.json +51 -0
- package/studio/src/lib/i18n/vi/onboarding.json +35 -0
- package/studio/src/lib/i18n/vi/playwrightTemplate.json +20 -0
- package/studio/src/lib/i18n/vi/practice.json +79 -0
- package/studio/src/lib/i18n/vi/provider.json +27 -0
- package/studio/src/lib/i18n/vi/providerMeta.json +26 -0
- package/studio/src/lib/i18n/vi/reflection.json +17 -0
- package/studio/src/lib/i18n/vi/revision.json +52 -0
- package/studio/src/lib/i18n/vi/runnerNotice.json +14 -0
- package/studio/src/lib/i18n/vi/settings.json +12 -0
- package/studio/src/lib/i18n/vi/storage.json +49 -0
- package/studio/src/lib/i18n/vi/tutor.json +39 -0
- package/studio/src/lib/i18n/vi/workspace.json +50 -0
- package/studio/src/lib/ids.js +10 -0
- package/studio/src/lib/json.js +26 -0
- package/studio/src/lib/markdown.js +74 -0
- package/studio/src/lib/prompts/base.js +139 -0
- package/studio/src/lib/prompts/capstone.js +777 -0
- package/studio/src/lib/prompts/course.js +150 -0
- package/studio/src/lib/prompts/lesson.js +275 -0
- package/studio/src/lib/prompts/practice.js +511 -0
- package/studio/src/lib/prompts/tutor.js +509 -0
- package/studio/src/lib/repo/blocked-adapter.js +69 -0
- package/studio/src/lib/repo/events.js +239 -0
- package/studio/src/lib/repo/idb-adapter.js +226 -0
- package/studio/src/lib/repo/index.js +1315 -0
- package/studio/src/lib/repo/memory-adapter.js +115 -0
- package/studio/src/lib/repo/remote-adapter.js +158 -0
- package/studio/src/lib/repo/schema.js +143 -0
- package/studio/src/lib/repo/select-adapter.js +345 -0
- package/studio/src/lib/router.js +71 -0
- package/studio/src/lib/runner/browser.js +244 -0
- package/studio/src/lib/runner/checks.js +248 -0
- package/studio/src/lib/runner/current-code.js +98 -0
- package/studio/src/lib/runner/esm-cjs.js +109 -0
- package/studio/src/lib/runner/hash.js +34 -0
- package/studio/src/lib/runner/history.js +48 -0
- package/studio/src/lib/runner/index.js +168 -0
- package/studio/src/lib/runner/languages.js +258 -0
- package/studio/src/lib/runner/local.js +139 -0
- package/studio/src/lib/runner/path.js +104 -0
- package/studio/src/lib/runner/skeleton.js +315 -0
- package/studio/src/lib/runner/strip-types.js +134 -0
- package/studio/src/lib/settings.js +399 -0
- package/studio/src/lib/srs.js +39 -0
- package/templates/agent/settings.json +6 -0
- package/templates/claude/plugin.json +139 -0
- package/templates/claude/settings.json +15 -0
- package/templates/cursor/professor.mdc +4 -0
- package/templates/cursor/settings.json +18 -0
- package/templates/gemini/GEMINI.md +28 -0
- package/templates/gemini/settings.json +5 -0
- package/templates/opencode/opencode.json +3 -0
- package/templates/researcher/README.md +61 -0
- package/templates/researcher/settings.json +18 -0
- package/web/client/components.json +15 -0
- package/web/client/index.html +12 -0
- package/web/client/package-lock.json +8732 -0
- package/web/client/package.json +39 -0
- package/web/client/postcss.config.js +6 -0
- package/web/client/src/App.jsx +140 -0
- package/web/client/src/api/client.js +357 -0
- package/web/client/src/components/ChatPanel.jsx +417 -0
- package/web/client/src/components/CommandPills.jsx +50 -0
- package/web/client/src/components/DeprecationBanner.jsx +21 -0
- package/web/client/src/components/ExerciseEditor.jsx +164 -0
- package/web/client/src/components/Header.jsx +119 -0
- package/web/client/src/components/LecturePanel.jsx +342 -0
- package/web/client/src/components/MobileTabSwitcher.jsx +51 -0
- package/web/client/src/components/SplitPane.jsx +42 -0
- package/web/client/src/components/ui/alert.jsx +38 -0
- package/web/client/src/components/ui/badge.jsx +24 -0
- package/web/client/src/components/ui/button.jsx +23 -0
- package/web/client/src/components/ui/select.jsx +74 -0
- package/web/client/src/hooks/useSSE.js +152 -0
- package/web/client/src/hooks/useWebSocket.js +98 -0
- package/web/client/src/index.css +23 -0
- package/web/client/src/lib/utils.js +6 -0
- package/web/client/src/main.jsx +12 -0
- package/web/client/src/utils/markdown.js +50 -0
- package/web/client/tailwind.config.js +8 -0
- package/web/client/vite.config.js +21 -0
- package/web/dist/assets/index-C1W0eXIQ.js +294 -0
- package/web/dist/assets/index-C3OUx70T.css +1 -0
- package/web/dist/index.html +13 -0
- package/web/lecture-archive.js +172 -0
- package/web/legacy-server.js +1277 -0
- package/web/package-lock.json +855 -0
- package/web/package.json +10 -0
- package/web/security.js +1016 -0
- package/web/server.js +390 -0
- package/web/studio-api/agents.js +580 -0
- package/web/studio-api/bridge.js +199 -0
- package/web/studio-api/db.js +66 -0
- package/web/studio-api/guards.js +201 -0
- package/web/studio-api/health.js +538 -0
- package/web/studio-api.js +214 -0
- package/web/studio-db.js +380 -0
- package/web/studio-runner.js +568 -0
|
@@ -0,0 +1,1315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository — the ONLY data API pages use.
|
|
3
|
+
*
|
|
4
|
+
* All business logic lives here. Adapters (idb / memory / remote) are dumb table-level stores;
|
|
5
|
+
* this module owns ids, `owner_id`, `created_at`/`updated_at`/`deleted_at` tombstones, ordering,
|
|
6
|
+
* cascades and derived views. Swapping to the SQLite server (Phase 26) is one `useAdapter()` call.
|
|
7
|
+
*/
|
|
8
|
+
import { createIdbAdapter } from './idb-adapter.js';
|
|
9
|
+
import { review as srsReview, newCardState } from '../srs.js';
|
|
10
|
+
import { dayKey } from '../course.js';
|
|
11
|
+
import { normalizeLanguage } from '../i18n/languages.js';
|
|
12
|
+
import {
|
|
13
|
+
buildEventRow,
|
|
14
|
+
buildRevisionLink,
|
|
15
|
+
checksList,
|
|
16
|
+
compactMeta,
|
|
17
|
+
REVISION_FEEDBACK_TABLES,
|
|
18
|
+
validateEventKind,
|
|
19
|
+
validateEventSurface,
|
|
20
|
+
validateMeta,
|
|
21
|
+
} from './events.js';
|
|
22
|
+
|
|
23
|
+
let adapter = createIdbAdapter();
|
|
24
|
+
const changes = new EventTarget();
|
|
25
|
+
|
|
26
|
+
/* ------------------------------------------------------------------ helpers */
|
|
27
|
+
|
|
28
|
+
function uuid() {
|
|
29
|
+
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
|
30
|
+
return `id-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function nowIso() {
|
|
34
|
+
return new Date().toISOString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {string} table
|
|
39
|
+
* @param {string|null} id
|
|
40
|
+
* @param {string} type
|
|
41
|
+
*/
|
|
42
|
+
function emit(table, id, type) {
|
|
43
|
+
changes.dispatchEvent(new CustomEvent('change', { detail: { table, id, type } }));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* -------------------------------------------------- best-effort side effects */
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Run a post-write side effect (event emission, revision link) without letting its failure reject
|
|
50
|
+
* the learner's action (D-16). Swallows once — no retry — and logs only in dev (D-17).
|
|
51
|
+
* @template T
|
|
52
|
+
* @param {string} label
|
|
53
|
+
* @param {() => Promise<T>} run
|
|
54
|
+
* @returns {Promise<T|null>}
|
|
55
|
+
*/
|
|
56
|
+
async function bestEffort(label, run) {
|
|
57
|
+
try {
|
|
58
|
+
return await run();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (import.meta.env?.DEV) console.warn('[repo] ' + label + ' failed', err);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Best-effort `recordEvent`. Validation inside `recordEvent` still throws — this only tolerates the
|
|
67
|
+
* failure at the call site, after the primary row is written (D-16/D-17).
|
|
68
|
+
* @param {string} label
|
|
69
|
+
* @param {object} input
|
|
70
|
+
* @returns {Promise<object|null>}
|
|
71
|
+
*/
|
|
72
|
+
function safeRecordEvent(label, input) {
|
|
73
|
+
return bestEffort(label, () => recordEvent(input));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Best-effort `maybeLinkRevision`. Like `safeRecordEvent`, the write failure is swallowed once.
|
|
78
|
+
* @param {string} label
|
|
79
|
+
* @param {object} input
|
|
80
|
+
* @returns {Promise<object|null>}
|
|
81
|
+
*/
|
|
82
|
+
function safeMaybeLinkRevision(label, input) {
|
|
83
|
+
return bestEffort(label, () => maybeLinkRevision(input));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Subscribe to repository writes.
|
|
88
|
+
* @param {(detail: { table: string, id: string|null, type: string }) => void} handler
|
|
89
|
+
* @returns {() => void} unsubscribe
|
|
90
|
+
*/
|
|
91
|
+
export function onChange(handler) {
|
|
92
|
+
const listener = (e) => handler(e.detail);
|
|
93
|
+
changes.addEventListener('change', listener);
|
|
94
|
+
return () => changes.removeEventListener('change', listener);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** @param {import('./remote-adapter.js').StorageAdapter} next */
|
|
98
|
+
export function useAdapter(next) {
|
|
99
|
+
adapter = next;
|
|
100
|
+
emit('adapter', null, 'use');
|
|
101
|
+
return adapter;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** @returns {Promise<{ kind: 'indexeddb'|'memory'|'remote', persistent: boolean }>} */
|
|
105
|
+
export function getAdapterInfo() {
|
|
106
|
+
return adapter.info();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function stampCreate(row, now = nowIso()) {
|
|
110
|
+
return {
|
|
111
|
+
...row,
|
|
112
|
+
id: row.id || uuid(),
|
|
113
|
+
owner_id: row.owner_id || 'local',
|
|
114
|
+
created_at: row.created_at || now,
|
|
115
|
+
updated_at: now,
|
|
116
|
+
deleted_at: row.deleted_at ?? null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function stampUpdate(prev, patch) {
|
|
121
|
+
const now = nowIso();
|
|
122
|
+
return {
|
|
123
|
+
...prev,
|
|
124
|
+
...patch,
|
|
125
|
+
owner_id: patch.owner_id ?? prev?.owner_id ?? 'local',
|
|
126
|
+
created_at: prev?.created_at ?? patch.created_at ?? now,
|
|
127
|
+
updated_at: now,
|
|
128
|
+
deleted_at: patch.deleted_at ?? prev?.deleted_at ?? null,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function mergeAllowed(prev, patch, allowed) {
|
|
133
|
+
const out = { ...prev };
|
|
134
|
+
for (const key of allowed) if (patch[key] !== undefined) out[key] = patch[key];
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* ------------------------------------------------------------------ revision helpers */
|
|
139
|
+
|
|
140
|
+
const REVISION_WINDOW_MS = 72 * 60 * 60 * 1000;
|
|
141
|
+
const TUTOR_ATTEMPT_MODES = Object.freeze(['check_answer', 'review']);
|
|
142
|
+
|
|
143
|
+
function toIsoTimestamp(now) {
|
|
144
|
+
if (now === undefined || now === null) return nowIso();
|
|
145
|
+
return (now instanceof Date ? now : new Date(now)).toISOString();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function createdAtMs(row) {
|
|
149
|
+
const value = Date.parse(row?.created_at);
|
|
150
|
+
return Number.isNaN(value) ? null : value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function sortByCreatedAt(rows, tiebreak = 'id') {
|
|
154
|
+
return [...rows].sort((a, b) => {
|
|
155
|
+
const aTime = createdAtMs(a);
|
|
156
|
+
const bTime = createdAtMs(b);
|
|
157
|
+
if (aTime !== bTime) return aTime - bTime;
|
|
158
|
+
const aKey = String(a?.[tiebreak] ?? '');
|
|
159
|
+
const bKey = String(b?.[tiebreak] ?? '');
|
|
160
|
+
return aKey < bKey ? -1 : aKey > bKey ? 1 : 0;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function latestPriorAttempt(rows, attemptRow, tiebreak = 'id') {
|
|
165
|
+
const newTime = createdAtMs(attemptRow);
|
|
166
|
+
if (newTime == null) return null;
|
|
167
|
+
const candidates = rows.filter((row) => row.id !== attemptRow.id && createdAtMs(row) != null && createdAtMs(row) <= newTime);
|
|
168
|
+
if (candidates.length === 0) return null;
|
|
169
|
+
const sorted = sortByCreatedAt(candidates, tiebreak);
|
|
170
|
+
return sorted[sorted.length - 1];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function runChecks(row) {
|
|
174
|
+
return checksList(parseJsonColumn(row?.checks_json, null));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function hasFailingCheck(row) {
|
|
178
|
+
return runChecks(row).some((check) => check?.passed === false || check?.ok === false);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function revisionCourseId({ lessonId = null, capstoneId = null } = {}) {
|
|
182
|
+
if (capstoneId) {
|
|
183
|
+
const capstone = await adapter.get('capstones', capstoneId);
|
|
184
|
+
return capstone?.course_id ?? null;
|
|
185
|
+
}
|
|
186
|
+
if (lessonId) {
|
|
187
|
+
const lesson = await adapter.get('lessons', lessonId);
|
|
188
|
+
return lesson?.course_id ?? null;
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function findPriorAttempt({ surface, attemptRow, lessonId = null, capstoneId = null }) {
|
|
194
|
+
if (surface === 'quiz') {
|
|
195
|
+
const rows = await adapter.list('quiz_attempts', { where: { lesson_id: lessonId } });
|
|
196
|
+
return latestPriorAttempt(rows, attemptRow);
|
|
197
|
+
}
|
|
198
|
+
if (surface === 'tutor') {
|
|
199
|
+
const rows = await adapter.list('chat_messages', { where: { lesson_id: lessonId } });
|
|
200
|
+
const attempts = rows.filter((row) => row.role === 'user' && TUTOR_ATTEMPT_MODES.includes(row.mode));
|
|
201
|
+
return latestPriorAttempt(attempts, attemptRow);
|
|
202
|
+
}
|
|
203
|
+
if (surface === 'workspace') {
|
|
204
|
+
const rows = await adapter.list('run_results', { where: { lesson_id: lessonId } });
|
|
205
|
+
const sameFile = rows.filter(
|
|
206
|
+
(row) => (row.file_id ?? null) === (attemptRow.file_id ?? null) && hasFailingCheck(row)
|
|
207
|
+
);
|
|
208
|
+
return latestPriorAttempt(sameFile, attemptRow);
|
|
209
|
+
}
|
|
210
|
+
if (surface === 'capstone') {
|
|
211
|
+
const rows = await adapter.list('capstone_submissions', { where: { capstone_id: capstoneId } });
|
|
212
|
+
const reviewed = rows.filter((row) => row.review_json != null || row.verdict != null);
|
|
213
|
+
return latestPriorAttempt(reviewed, attemptRow, 'attempt');
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Load a caller-supplied feedback row only through the fixed allowlist (T-05-08). */
|
|
219
|
+
async function loadExplicitFeedback(revisionOf) {
|
|
220
|
+
if (!revisionOf || typeof revisionOf !== 'object') return null;
|
|
221
|
+
const { feedbackTable, feedbackId } = revisionOf;
|
|
222
|
+
if (!feedbackTable || !feedbackId) return null;
|
|
223
|
+
if (!REVISION_FEEDBACK_TABLES.includes(feedbackTable)) return null;
|
|
224
|
+
const row = await adapter.get(feedbackTable, feedbackId);
|
|
225
|
+
if (!row || row.deleted_at) return null;
|
|
226
|
+
return { table: feedbackTable, id: feedbackId, row };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function latestByCreatedAt(rows, tiebreak = 'id') {
|
|
230
|
+
const valid = rows.filter((row) => createdAtMs(row) != null);
|
|
231
|
+
if (valid.length === 0) return null;
|
|
232
|
+
const sorted = sortByCreatedAt(valid, tiebreak);
|
|
233
|
+
return sorted[sorted.length - 1];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Resolve the D-07 feedback row for one surface. Workspace and capstone are their own feedback.
|
|
238
|
+
* @param {{ surface: string, attemptRow: object, prior: object|null, lessonId?: string|null }} input
|
|
239
|
+
*/
|
|
240
|
+
async function findInferenceFeedback({ surface, attemptRow, prior, lessonId = null }) {
|
|
241
|
+
if (surface === 'workspace' || surface === 'capstone') return prior;
|
|
242
|
+
if (surface !== 'quiz' && surface !== 'tutor') return null;
|
|
243
|
+
const priorTime = createdAtMs(prior);
|
|
244
|
+
const newTime = createdAtMs(attemptRow);
|
|
245
|
+
if (priorTime == null || newTime == null) return null;
|
|
246
|
+
const rows = await adapter.list('chat_messages', { where: { lesson_id: lessonId } });
|
|
247
|
+
const candidates = rows.filter((row) => {
|
|
248
|
+
if (row.role !== 'assistant') return false;
|
|
249
|
+
if (surface === 'quiz' && row.mode !== 'review') return false;
|
|
250
|
+
const at = createdAtMs(row);
|
|
251
|
+
return at != null && at > priorTime && at <= newTime;
|
|
252
|
+
});
|
|
253
|
+
return latestByCreatedAt(candidates);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Write at most one revision link for a just-created attempt row (D-06/D-07).
|
|
258
|
+
* @param {{ surface: string, attemptTable: string, attemptRow: object, lessonId?: string|null, capstoneId?: string|null, revisionOf?: object|null, now?: Date|string }} input
|
|
259
|
+
*/
|
|
260
|
+
async function maybeLinkRevision({
|
|
261
|
+
surface,
|
|
262
|
+
attemptTable,
|
|
263
|
+
attemptRow,
|
|
264
|
+
lessonId = null,
|
|
265
|
+
capstoneId = null,
|
|
266
|
+
revisionOf = null,
|
|
267
|
+
now,
|
|
268
|
+
}) {
|
|
269
|
+
const existing = await adapter.list('revision_links', { where: { revised_id: attemptRow.id } });
|
|
270
|
+
if (existing.length > 0) return existing[0];
|
|
271
|
+
|
|
272
|
+
const courseId = await revisionCourseId({ lessonId, capstoneId });
|
|
273
|
+
const prior = await findPriorAttempt({ surface, attemptRow, lessonId, capstoneId });
|
|
274
|
+
const explicit = await loadExplicitFeedback(revisionOf);
|
|
275
|
+
if (explicit) {
|
|
276
|
+
const row = stampCreate(
|
|
277
|
+
buildRevisionLink({
|
|
278
|
+
surface,
|
|
279
|
+
courseId,
|
|
280
|
+
lessonId,
|
|
281
|
+
attemptTable,
|
|
282
|
+
attemptId: prior?.id ?? null,
|
|
283
|
+
feedbackTable: explicit.table,
|
|
284
|
+
feedbackId: explicit.id,
|
|
285
|
+
revisedTable: attemptTable,
|
|
286
|
+
revisedId: attemptRow.id,
|
|
287
|
+
linkKind: 'explicit',
|
|
288
|
+
}),
|
|
289
|
+
toIsoTimestamp(now)
|
|
290
|
+
);
|
|
291
|
+
await adapter.put('revision_links', row);
|
|
292
|
+
return row;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const feedback = await findInferenceFeedback({ surface, attemptRow, prior, lessonId });
|
|
296
|
+
if (!prior || !feedback) return null;
|
|
297
|
+
|
|
298
|
+
const priorTime = createdAtMs(prior);
|
|
299
|
+
const newTime = createdAtMs(attemptRow);
|
|
300
|
+
if (priorTime == null || newTime == null) return null;
|
|
301
|
+
const span = newTime - priorTime;
|
|
302
|
+
if (span < 0 || span > REVISION_WINDOW_MS) return null;
|
|
303
|
+
|
|
304
|
+
const feedbackTable =
|
|
305
|
+
surface === 'workspace' ? 'run_results' : surface === 'capstone' ? 'capstone_submissions' : 'chat_messages';
|
|
306
|
+
const row = stampCreate(
|
|
307
|
+
buildRevisionLink({
|
|
308
|
+
surface,
|
|
309
|
+
courseId,
|
|
310
|
+
lessonId,
|
|
311
|
+
attemptTable,
|
|
312
|
+
attemptId: prior.id,
|
|
313
|
+
feedbackTable,
|
|
314
|
+
feedbackId: feedback.id,
|
|
315
|
+
revisedTable: attemptTable,
|
|
316
|
+
revisedId: attemptRow.id,
|
|
317
|
+
linkKind: 'inferred',
|
|
318
|
+
}),
|
|
319
|
+
toIsoTimestamp(now)
|
|
320
|
+
);
|
|
321
|
+
await adapter.put('revision_links', row);
|
|
322
|
+
return row;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* ------------------------------------------------------------------ courses */
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Migrate a stored course row's legacy `language` display name to a BCP-47 code on READ (LANG-01).
|
|
329
|
+
* Writes keep whatever the caller supplied, so the raw row stays byte-identical for backups.
|
|
330
|
+
* @param {object|null} row
|
|
331
|
+
*/
|
|
332
|
+
export function decodeCourse(row) {
|
|
333
|
+
if (!row) return row;
|
|
334
|
+
if (row.language === null || row.language === undefined) return row;
|
|
335
|
+
return { ...row, language: normalizeLanguage(row.language) };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** @returns {Promise<object[]>} */
|
|
339
|
+
export async function listCourses() {
|
|
340
|
+
const rows = await adapter.list('courses', { orderBy: { field: 'updated_at', dir: 'desc' } });
|
|
341
|
+
return rows.map(decodeCourse);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* @param {string} id
|
|
346
|
+
* @returns {Promise<(object & { lessons: object[] }) | null>}
|
|
347
|
+
*/
|
|
348
|
+
export async function getCourse(id) {
|
|
349
|
+
const course = await adapter.get('courses', id);
|
|
350
|
+
if (!course || course.deleted_at) return null;
|
|
351
|
+
const lessons = await adapter.list('lessons', {
|
|
352
|
+
where: { course_id: id },
|
|
353
|
+
orderBy: { field: 'position', dir: 'asc' },
|
|
354
|
+
});
|
|
355
|
+
return { ...decodeCourse(course), lessons };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* @param {object} input
|
|
360
|
+
* @param {string} input.title
|
|
361
|
+
* @param {string|null} [input.externalId] stable id for a bundled template course
|
|
362
|
+
* @param {Array<object|string>} [input.lessons]
|
|
363
|
+
* @returns {Promise<object & { lessons: object[] }>}
|
|
364
|
+
*/
|
|
365
|
+
export async function createCourse(input = {}) {
|
|
366
|
+
const now = nowIso();
|
|
367
|
+
const course = stampCreate(
|
|
368
|
+
{
|
|
369
|
+
id: input.id,
|
|
370
|
+
title: input.title || 'Untitled course',
|
|
371
|
+
description: input.description || '',
|
|
372
|
+
level: input.level || 'Beginner',
|
|
373
|
+
kind: input.kind || 'conceptual',
|
|
374
|
+
goal: input.goal || '',
|
|
375
|
+
language: input.language || null,
|
|
376
|
+
topics_json: JSON.stringify(input.topics || []),
|
|
377
|
+
source: input.source || 'ai',
|
|
378
|
+
status: input.status || 'active',
|
|
379
|
+
external_id: input.externalId || null,
|
|
380
|
+
},
|
|
381
|
+
now
|
|
382
|
+
);
|
|
383
|
+
await adapter.put('courses', course);
|
|
384
|
+
|
|
385
|
+
const lessons = [];
|
|
386
|
+
const inputLessons = input.lessons || [];
|
|
387
|
+
for (let i = 0; i < inputLessons.length; i++) {
|
|
388
|
+
const raw = typeof inputLessons[i] === 'string' ? { title: inputLessons[i] } : inputLessons[i];
|
|
389
|
+
let lesson = stampCreate(
|
|
390
|
+
{
|
|
391
|
+
id: raw.id,
|
|
392
|
+
course_id: course.id,
|
|
393
|
+
position: i + 1,
|
|
394
|
+
title: raw.title || `Lesson ${i + 1}`,
|
|
395
|
+
summary: raw.summary || '',
|
|
396
|
+
status: raw.status || 'todo',
|
|
397
|
+
completed_at: raw.completed_at || null,
|
|
398
|
+
current_version_id: null,
|
|
399
|
+
quiz_seed: raw.quiz_seed ? JSON.stringify(raw.quiz_seed) : null,
|
|
400
|
+
},
|
|
401
|
+
now
|
|
402
|
+
);
|
|
403
|
+
await adapter.put('lessons', lesson);
|
|
404
|
+
if (raw.content_md) {
|
|
405
|
+
const version = await addLessonVersion(lesson.id, {
|
|
406
|
+
content_md: raw.content_md,
|
|
407
|
+
model: raw.model || 'demo',
|
|
408
|
+
provider: raw.provider || 'demo',
|
|
409
|
+
});
|
|
410
|
+
lesson = { ...lesson, current_version_id: version.id };
|
|
411
|
+
}
|
|
412
|
+
lessons.push(lesson);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
emit('courses', course.id, 'create');
|
|
416
|
+
return { ...course, lessons };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* @param {string} id
|
|
421
|
+
* @param {{ title?: string, description?: string, level?: string, goal?: string, status?: string, language?: string|null, topics?: string[] }} patch
|
|
422
|
+
*/
|
|
423
|
+
export async function updateCourse(id, patch = {}) {
|
|
424
|
+
const prev = await adapter.get('courses', id);
|
|
425
|
+
if (!prev) return null;
|
|
426
|
+
const merged = mergeAllowed(prev, patch, ['title', 'description', 'level', 'goal', 'status', 'source', 'language']);
|
|
427
|
+
if (patch.topics !== undefined) merged.topics_json = JSON.stringify(patch.topics);
|
|
428
|
+
const next = stampUpdate(prev, merged);
|
|
429
|
+
await adapter.put('courses', next);
|
|
430
|
+
emit('courses', id, 'update');
|
|
431
|
+
return next;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Soft-delete a course and cascade its lessons, cards and notes. */
|
|
435
|
+
export async function deleteCourse(id) {
|
|
436
|
+
const course = await adapter.get('courses', id);
|
|
437
|
+
if (!course) return null;
|
|
438
|
+
await adapter.softDelete('courses', id);
|
|
439
|
+
for (const table of ['lessons', 'cards', 'notes', 'exercises', 'exercise_attempts']) {
|
|
440
|
+
const rows = await adapter.list(table, { where: { course_id: id } });
|
|
441
|
+
for (const row of rows) await adapter.softDelete(table, row.id);
|
|
442
|
+
}
|
|
443
|
+
emit('courses', id, 'delete');
|
|
444
|
+
return { ...course, deleted_at: nowIso() };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/* ------------------------------------------------------------------ lessons */
|
|
448
|
+
|
|
449
|
+
/** @param {string} id */
|
|
450
|
+
export async function getLesson(id) {
|
|
451
|
+
const lesson = await adapter.get('lessons', id);
|
|
452
|
+
return lesson && !lesson.deleted_at ? lesson : null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* @param {string} id
|
|
457
|
+
* @param {{ title?: string, summary?: string, status?: string, completed_at?: string, position?: number, quiz_seed?: any }} patch
|
|
458
|
+
*/
|
|
459
|
+
export async function updateLesson(id, patch = {}) {
|
|
460
|
+
const prev = await adapter.get('lessons', id);
|
|
461
|
+
if (!prev) return null;
|
|
462
|
+
const merged = mergeAllowed(prev, patch, [
|
|
463
|
+
'title',
|
|
464
|
+
'summary',
|
|
465
|
+
'status',
|
|
466
|
+
'completed_at',
|
|
467
|
+
'position',
|
|
468
|
+
'current_version_id',
|
|
469
|
+
]);
|
|
470
|
+
if (patch.quiz_seed !== undefined) {
|
|
471
|
+
merged.quiz_seed = typeof patch.quiz_seed === 'string' ? patch.quiz_seed : JSON.stringify(patch.quiz_seed);
|
|
472
|
+
}
|
|
473
|
+
const next = stampUpdate(prev, merged);
|
|
474
|
+
if (merged.status === 'done' && !next.completed_at) next.completed_at = nowIso();
|
|
475
|
+
await adapter.put('lessons', next);
|
|
476
|
+
emit('lessons', id, 'update');
|
|
477
|
+
return next;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/* ------------------------------------------------------------------ versions */
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Append a new immutable version. Regenerating a lesson ADDS a row, never overwrites.
|
|
484
|
+
* @param {string} lessonId
|
|
485
|
+
* @param {{ content_md?: string, model?: string, provider?: string }} data
|
|
486
|
+
* @returns {Promise<object>} the new version row
|
|
487
|
+
*/
|
|
488
|
+
export async function addLessonVersion(lessonId, { content_md = '', model = null, provider = null, language = null } = {}) {
|
|
489
|
+
const lesson = await adapter.get('lessons', lessonId);
|
|
490
|
+
if (!lesson || lesson.deleted_at) throw new Error(`Lesson not found: ${lessonId}`);
|
|
491
|
+
const existing = await adapter.list('lesson_versions', { where: { lesson_id: lessonId } });
|
|
492
|
+
const version = existing.reduce((max, v) => Math.max(max, v.version || 0), 0) + 1;
|
|
493
|
+
const row = stampCreate({ lesson_id: lessonId, version, content_md, model, provider, language });
|
|
494
|
+
await adapter.put('lesson_versions', row);
|
|
495
|
+
await adapter.put('lessons', stampUpdate(lesson, { current_version_id: row.id }));
|
|
496
|
+
emit('lesson_versions', row.id, 'create');
|
|
497
|
+
return row;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** @param {string} lessonId */
|
|
501
|
+
export async function listLessonVersions(lessonId) {
|
|
502
|
+
return adapter.list('lesson_versions', {
|
|
503
|
+
where: { lesson_id: lessonId },
|
|
504
|
+
orderBy: { field: 'version', dir: 'asc' },
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Current version row for a lesson (falls back to the highest version number).
|
|
510
|
+
* @param {string} lessonId
|
|
511
|
+
* @returns {Promise<object|null>}
|
|
512
|
+
*/
|
|
513
|
+
export async function getLessonContent(lessonId) {
|
|
514
|
+
const lesson = await adapter.get('lessons', lessonId);
|
|
515
|
+
if (!lesson) return null;
|
|
516
|
+
if (lesson.current_version_id) {
|
|
517
|
+
const row = await adapter.get('lesson_versions', lesson.current_version_id);
|
|
518
|
+
if (row && !row.deleted_at) return row;
|
|
519
|
+
}
|
|
520
|
+
const rows = await adapter.list('lesson_versions', {
|
|
521
|
+
where: { lesson_id: lessonId },
|
|
522
|
+
orderBy: { field: 'version', dir: 'desc' },
|
|
523
|
+
limit: 1,
|
|
524
|
+
});
|
|
525
|
+
return rows[0] || null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/* ------------------------------------------------------------------ practice */
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* @param {string} lessonId
|
|
532
|
+
* @param {{ questions?: any[], answers?: any[], score?: number, total?: number, revisionOf?: object|null }} data
|
|
533
|
+
*/
|
|
534
|
+
export async function saveQuizAttempt(
|
|
535
|
+
lessonId,
|
|
536
|
+
{ questions = [], answers = [], score = null, total = null, revisionOf = null } = {}
|
|
537
|
+
) {
|
|
538
|
+
const row = stampCreate({
|
|
539
|
+
lesson_id: lessonId,
|
|
540
|
+
questions_json: JSON.stringify(questions),
|
|
541
|
+
answers_json: JSON.stringify(answers),
|
|
542
|
+
score,
|
|
543
|
+
total,
|
|
544
|
+
});
|
|
545
|
+
await adapter.put('quiz_attempts', row);
|
|
546
|
+
const lesson = lessonId ? await adapter.get('lessons', lessonId) : null;
|
|
547
|
+
const meta = compactMeta({ total, score, answered: Array.isArray(answers) ? answers.length : 0 });
|
|
548
|
+
await safeRecordEvent('quiz_completed event', {
|
|
549
|
+
kind: 'quiz_completed',
|
|
550
|
+
surface: 'quiz',
|
|
551
|
+
courseId: lesson?.course_id ?? null,
|
|
552
|
+
lessonId,
|
|
553
|
+
refId: row.id,
|
|
554
|
+
meta,
|
|
555
|
+
});
|
|
556
|
+
await safeMaybeLinkRevision('quiz revision link', {
|
|
557
|
+
surface: 'quiz',
|
|
558
|
+
attemptTable: 'quiz_attempts',
|
|
559
|
+
attemptRow: row,
|
|
560
|
+
lessonId,
|
|
561
|
+
revisionOf,
|
|
562
|
+
});
|
|
563
|
+
emit('quiz_attempts', row.id, 'create');
|
|
564
|
+
return row;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** @param {string} lessonId */
|
|
568
|
+
export async function listQuizAttempts(lessonId) {
|
|
569
|
+
return adapter.list('quiz_attempts', {
|
|
570
|
+
where: { lesson_id: lessonId },
|
|
571
|
+
orderBy: { field: 'created_at', dir: 'asc' },
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/* ------------------------------------------------------------------ exercises */
|
|
576
|
+
|
|
577
|
+
function parseJson(text, fallback) {
|
|
578
|
+
try {
|
|
579
|
+
const value = JSON.parse(text);
|
|
580
|
+
return value === null || value === undefined ? fallback : value;
|
|
581
|
+
} catch {
|
|
582
|
+
return fallback;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function decodeExercise(row) {
|
|
587
|
+
if (!row) return null;
|
|
588
|
+
const { spec_json: specJson, ...rest } = row;
|
|
589
|
+
// Plain JSON only: this module is on the CLI's import graph, where the Studio-only zod dependency
|
|
590
|
+
// is absent. Widgets normalise the spec through `lib/exercises/spec.js` when they render it.
|
|
591
|
+
return { ...rest, spec: parseJson(specJson, null) };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
function decodeExerciseAttempt(row) {
|
|
595
|
+
if (!row) return null;
|
|
596
|
+
const { rows_json: rowsJson, result_json: resultJson, ...rest } = row;
|
|
597
|
+
return {
|
|
598
|
+
...rest,
|
|
599
|
+
rows: parseJson(rowsJson, []),
|
|
600
|
+
result: parseJson(resultJson, null),
|
|
601
|
+
correct: Boolean(row.correct),
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Persist one authored exercise. The spec is validated first, so a broken or answer-leaking spec
|
|
607
|
+
* never reaches storage.
|
|
608
|
+
* @param {{ courseId: string, lessonId: string, position?: number, type?: string, spec: object|string, externalId?: string|null }} input
|
|
609
|
+
* @returns {Promise<object>} the decoded row (with `spec`)
|
|
610
|
+
*/
|
|
611
|
+
export async function createExercise({
|
|
612
|
+
courseId,
|
|
613
|
+
lessonId,
|
|
614
|
+
position = 1,
|
|
615
|
+
type = 'journal-entry',
|
|
616
|
+
spec,
|
|
617
|
+
externalId = null,
|
|
618
|
+
} = {}) {
|
|
619
|
+
if (!courseId) throw new Error('createExercise: courseId is required');
|
|
620
|
+
if (!lessonId) throw new Error('createExercise: lessonId is required');
|
|
621
|
+
const raw = typeof spec === 'string' ? JSON.parse(spec) : spec;
|
|
622
|
+
// Loaded lazily: zod is a Studio-only dependency, and a static import here would break the
|
|
623
|
+
// published CLI (`skillforce import`) with ERR_MODULE_NOT_FOUND.
|
|
624
|
+
const { validateExerciseSpec } = await import('../exercises/spec.js');
|
|
625
|
+
const clean = validateExerciseSpec({ ...raw, type });
|
|
626
|
+
const row = stampCreate({
|
|
627
|
+
course_id: courseId,
|
|
628
|
+
lesson_id: lessonId,
|
|
629
|
+
position,
|
|
630
|
+
type: clean.type,
|
|
631
|
+
spec_json: JSON.stringify(clean),
|
|
632
|
+
external_id: externalId,
|
|
633
|
+
});
|
|
634
|
+
await adapter.put('exercises', row);
|
|
635
|
+
emit('exercises', row.id, 'create');
|
|
636
|
+
return decodeExercise(row);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* @param {string} lessonId
|
|
641
|
+
* @returns {Promise<object[]>} decoded exercises in `position` order
|
|
642
|
+
*/
|
|
643
|
+
export async function listLessonExercises(lessonId) {
|
|
644
|
+
const rows = await adapter.list('exercises', { where: { lesson_id: lessonId } });
|
|
645
|
+
return rows.map(decodeExercise).sort((a, b) => a.position - b.position);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* @param {string} courseId
|
|
650
|
+
* @returns {Promise<Record<string, object[]>>} decoded exercises grouped by `lesson_id`, ordered
|
|
651
|
+
*/
|
|
652
|
+
export async function listCourseExercises(courseId) {
|
|
653
|
+
const rows = await adapter.list('exercises', { where: { course_id: courseId } });
|
|
654
|
+
const grouped = {};
|
|
655
|
+
for (const row of rows.map(decodeExercise)) {
|
|
656
|
+
(grouped[row.lesson_id] ||= []).push(row);
|
|
657
|
+
}
|
|
658
|
+
for (const list of Object.values(grouped)) list.sort((a, b) => a.position - b.position);
|
|
659
|
+
return grouped;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Append one attempt. Rows and the grading result are stored verbatim so the learner can review
|
|
664
|
+
* past attempts after a reload; `correct` is stored as 0/1 and decoded to a boolean on read.
|
|
665
|
+
* @param {string} exerciseId
|
|
666
|
+
* @param {{ courseId?: string|null, lessonId?: string|null, rows?: any, result?: object|null }} data
|
|
667
|
+
* @returns {Promise<object>}
|
|
668
|
+
*/
|
|
669
|
+
export async function saveExerciseAttempt(
|
|
670
|
+
exerciseId,
|
|
671
|
+
{ courseId = null, lessonId = null, rows = [], result = null } = {}
|
|
672
|
+
) {
|
|
673
|
+
const exercise = await adapter.get('exercises', exerciseId);
|
|
674
|
+
if (!exercise || exercise.deleted_at) throw new Error(`Exercise not found: ${exerciseId}`);
|
|
675
|
+
const course = courseId ?? exercise.course_id ?? null;
|
|
676
|
+
const lesson = lessonId ?? exercise.lesson_id ?? null;
|
|
677
|
+
const row = stampCreate({
|
|
678
|
+
course_id: course,
|
|
679
|
+
lesson_id: lesson,
|
|
680
|
+
exercise_id: exerciseId,
|
|
681
|
+
rows_json: JSON.stringify(rows || []),
|
|
682
|
+
result_json: JSON.stringify(result || null),
|
|
683
|
+
correct: result?.correct ? 1 : 0,
|
|
684
|
+
});
|
|
685
|
+
await adapter.put('exercise_attempts', row);
|
|
686
|
+
await logActivity('exercise', lesson);
|
|
687
|
+
emit('exercise_attempts', row.id, 'create');
|
|
688
|
+
return decodeExerciseAttempt(row);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* @param {string} exerciseId
|
|
693
|
+
* @returns {Promise<object[]>} decoded attempts, oldest first
|
|
694
|
+
*/
|
|
695
|
+
export async function listExerciseAttempts(exerciseId) {
|
|
696
|
+
const rows = await adapter.list('exercise_attempts', {
|
|
697
|
+
where: { exercise_id: exerciseId },
|
|
698
|
+
orderBy: { field: 'created_at', dir: 'asc' },
|
|
699
|
+
});
|
|
700
|
+
return rows.map(decodeExerciseAttempt);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* @param {string|null} courseId
|
|
705
|
+
* @param {string|null} lessonId
|
|
706
|
+
* @param {Array<{ front: string, back: string }>} cards
|
|
707
|
+
*/
|
|
708
|
+
export async function addCards(courseId, lessonId, cards = []) {
|
|
709
|
+
const now = nowIso();
|
|
710
|
+
const state = newCardState();
|
|
711
|
+
const rows = cards.map((c) =>
|
|
712
|
+
stampCreate(
|
|
713
|
+
{
|
|
714
|
+
course_id: courseId || null,
|
|
715
|
+
lesson_id: lessonId || null,
|
|
716
|
+
front: c.front,
|
|
717
|
+
back: c.back,
|
|
718
|
+
ease: state.ease,
|
|
719
|
+
interval_days: state.interval,
|
|
720
|
+
reps: state.reps,
|
|
721
|
+
lapses: state.lapses,
|
|
722
|
+
due_at: new Date(state.due).toISOString(),
|
|
723
|
+
},
|
|
724
|
+
now
|
|
725
|
+
)
|
|
726
|
+
);
|
|
727
|
+
await adapter.putMany('cards', rows);
|
|
728
|
+
emit('cards', null, 'create');
|
|
729
|
+
return rows;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Cards across every course (optionally scoped), soonest due first. Soft-deleted rows are omitted
|
|
734
|
+
* by the adapter (HANDOFF-24 §1).
|
|
735
|
+
* @param {{ courseId?: string|null, lessonId?: string|null }} [q]
|
|
736
|
+
*/
|
|
737
|
+
export async function listCards({ courseId, lessonId } = {}) {
|
|
738
|
+
const where = {};
|
|
739
|
+
if (courseId) where.course_id = courseId;
|
|
740
|
+
if (lessonId) where.lesson_id = lessonId;
|
|
741
|
+
return adapter.list('cards', { where, orderBy: { field: 'due_at', dir: 'asc' } });
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Due cards across every course, soonest first.
|
|
746
|
+
* @param {number|string} [now]
|
|
747
|
+
*/
|
|
748
|
+
export async function listDueCards(now = Date.now()) {
|
|
749
|
+
const at = typeof now === 'number' ? now : Date.parse(now);
|
|
750
|
+
const cards = await adapter.list('cards', { orderBy: { field: 'due_at', dir: 'asc' } });
|
|
751
|
+
return cards.filter((c) => {
|
|
752
|
+
const due = Date.parse(c.due_at);
|
|
753
|
+
return Number.isNaN(due) || due <= at;
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Grade a card with SM-2 and record the review.
|
|
759
|
+
* @param {string} cardId
|
|
760
|
+
* @param {number} grade 0 again | 1 hard | 2 good | 3 easy
|
|
761
|
+
*/
|
|
762
|
+
export async function gradeCard(cardId, grade) {
|
|
763
|
+
const card = await adapter.get('cards', cardId);
|
|
764
|
+
if (!card) throw new Error(`Card not found: ${cardId}`);
|
|
765
|
+
const nextState = srsReview(
|
|
766
|
+
{
|
|
767
|
+
ease: card.ease,
|
|
768
|
+
interval: card.interval_days,
|
|
769
|
+
reps: card.reps,
|
|
770
|
+
lapses: card.lapses,
|
|
771
|
+
due: Date.parse(card.due_at),
|
|
772
|
+
},
|
|
773
|
+
grade
|
|
774
|
+
);
|
|
775
|
+
const next = stampUpdate(card, {
|
|
776
|
+
ease: nextState.ease,
|
|
777
|
+
interval_days: nextState.interval,
|
|
778
|
+
reps: nextState.reps,
|
|
779
|
+
lapses: nextState.lapses,
|
|
780
|
+
due_at: new Date(nextState.due).toISOString(),
|
|
781
|
+
});
|
|
782
|
+
await adapter.put('cards', next);
|
|
783
|
+
const review = stampCreate({ card_id: cardId, grade, reviewed_at: nowIso() });
|
|
784
|
+
await adapter.put('reviews', review);
|
|
785
|
+
await recordEvent({
|
|
786
|
+
kind: 'recall_completed',
|
|
787
|
+
surface: 'recall',
|
|
788
|
+
courseId: card.course_id ?? null,
|
|
789
|
+
lessonId: card.lesson_id ?? null,
|
|
790
|
+
refId: review.id,
|
|
791
|
+
meta: { grade },
|
|
792
|
+
});
|
|
793
|
+
emit('cards', cardId, 'grade');
|
|
794
|
+
return { card: next, review };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/* ------------------------------------------------------------------ tutor chat */
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* @param {string} lessonId
|
|
801
|
+
* @param {{ role: string, content: string, mode?: string, revisionOf?: { feedbackTable: string, feedbackId: string }|null }} msg
|
|
802
|
+
*/
|
|
803
|
+
export async function appendChat(lessonId, msg) {
|
|
804
|
+
const row = stampCreate({ lesson_id: lessonId, role: msg.role, content: msg.content, mode: msg.mode || null });
|
|
805
|
+
await adapter.put('chat_messages', row);
|
|
806
|
+
if (msg.role === 'user' && (msg.mode === 'check_answer' || msg.mode === 'review')) {
|
|
807
|
+
const lesson = lessonId ? await adapter.get('lessons', lessonId) : null;
|
|
808
|
+
await safeRecordEvent('answer_sent_for_review event', {
|
|
809
|
+
kind: 'answer_sent_for_review',
|
|
810
|
+
surface: 'tutor',
|
|
811
|
+
courseId: lesson?.course_id ?? null,
|
|
812
|
+
lessonId,
|
|
813
|
+
refId: row.id,
|
|
814
|
+
meta: { mode: msg.mode },
|
|
815
|
+
});
|
|
816
|
+
await safeMaybeLinkRevision('tutor revision link', {
|
|
817
|
+
surface: 'tutor',
|
|
818
|
+
attemptTable: 'chat_messages',
|
|
819
|
+
attemptRow: row,
|
|
820
|
+
lessonId,
|
|
821
|
+
revisionOf: msg.revisionOf ?? null,
|
|
822
|
+
});
|
|
823
|
+
}
|
|
824
|
+
emit('chat_messages', row.id, 'create');
|
|
825
|
+
return row;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** @param {string} lessonId */
|
|
829
|
+
export async function listChat(lessonId) {
|
|
830
|
+
return adapter.list('chat_messages', {
|
|
831
|
+
where: { lesson_id: lessonId },
|
|
832
|
+
orderBy: { field: 'created_at', dir: 'asc' },
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/* ------------------------------------------------------------------ reflection & notes */
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* @param {string} lessonId
|
|
840
|
+
* @param {{ hardest?: string, confidence?: number, note?: string }} data
|
|
841
|
+
*/
|
|
842
|
+
export async function saveReflection(lessonId, data = {}) {
|
|
843
|
+
const row = stampCreate({
|
|
844
|
+
lesson_id: lessonId,
|
|
845
|
+
hardest: data.hardest || '',
|
|
846
|
+
confidence: data.confidence ?? null,
|
|
847
|
+
note: data.note || '',
|
|
848
|
+
});
|
|
849
|
+
await adapter.put('reflections', row);
|
|
850
|
+
emit('reflections', row.id, 'create');
|
|
851
|
+
return row;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** @param {{ courseId?: string|null, lessonId?: string|null, content_md?: string }} input */
|
|
855
|
+
export async function saveNote({ courseId = null, lessonId = null, content_md = '' } = {}) {
|
|
856
|
+
const row = stampCreate({ course_id: courseId, lesson_id: lessonId, content_md });
|
|
857
|
+
await adapter.put('notes', row);
|
|
858
|
+
emit('notes', row.id, 'create');
|
|
859
|
+
return row;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/** @param {{ courseId?: string, lessonId?: string }} [q] */
|
|
863
|
+
export async function listNotes({ courseId, lessonId } = {}) {
|
|
864
|
+
const where = {};
|
|
865
|
+
if (courseId) where.course_id = courseId;
|
|
866
|
+
if (lessonId) where.lesson_id = lessonId;
|
|
867
|
+
return adapter.list('notes', { where, orderBy: { field: 'created_at', dir: 'asc' } });
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Everything recorded for a lesson (DATA-03).
|
|
872
|
+
* @param {string} lessonId
|
|
873
|
+
*/
|
|
874
|
+
export async function getLessonHistory(lessonId) {
|
|
875
|
+
const [versions, attempts, chat, notes, reflections] = await Promise.all([
|
|
876
|
+
listLessonVersions(lessonId),
|
|
877
|
+
listQuizAttempts(lessonId),
|
|
878
|
+
listChat(lessonId),
|
|
879
|
+
adapter.list('notes', { where: { lesson_id: lessonId }, orderBy: { field: 'created_at', dir: 'asc' } }),
|
|
880
|
+
adapter.list('reflections', { where: { lesson_id: lessonId }, orderBy: { field: 'created_at', dir: 'desc' } }),
|
|
881
|
+
]);
|
|
882
|
+
return { versions, attempts, chat, notes, reflection: reflections[0] || null };
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/* ------------------------------------------------------------------ code workspace (Phase 29 groundwork) */
|
|
886
|
+
|
|
887
|
+
/** @param {string} lessonId */
|
|
888
|
+
export async function listWorkspaceFiles(lessonId) {
|
|
889
|
+
return adapter.list('workspace_files', { where: { lesson_id: lessonId }, orderBy: { field: 'path', dir: 'asc' } });
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* Upsert a workspace file by (lesson_id, path). Versioning snapshots are Phase 29's job.
|
|
894
|
+
* @param {{ courseId?: string|null, lessonId: string, path: string, language?: string|null, content?: string }} input
|
|
895
|
+
*/
|
|
896
|
+
export async function saveWorkspaceFile({ courseId = null, lessonId, path, language = null, content = '' }) {
|
|
897
|
+
const existing = await adapter.list('workspace_files', { where: { lesson_id: lessonId } });
|
|
898
|
+
const match = existing.find((f) => f.path === path);
|
|
899
|
+
const row = match
|
|
900
|
+
? stampUpdate(match, { course_id: courseId ?? match.course_id, language, content })
|
|
901
|
+
: stampCreate({ course_id: courseId, lesson_id: lessonId, path, language, content });
|
|
902
|
+
await adapter.put('workspace_files', row);
|
|
903
|
+
emit('workspace_files', row.id, match ? 'update' : 'create');
|
|
904
|
+
return row;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Soft-delete a workspace file by (lesson_id, path). The tombstone keeps the row (and its run
|
|
909
|
+
* history) while `listWorkspaceFiles` hides it — replaces the DELETED_MARKER workaround.
|
|
910
|
+
* @param {{ lessonId: string, path: string }} input
|
|
911
|
+
* @returns {Promise<object|null>} the tombstoned row, or null when there was no such file
|
|
912
|
+
*/
|
|
913
|
+
export async function deleteWorkspaceFile({ lessonId, path }) {
|
|
914
|
+
const rows = await adapter.list('workspace_files', { where: { lesson_id: lessonId } });
|
|
915
|
+
const match = rows.find((f) => f.path === path);
|
|
916
|
+
if (!match) return null;
|
|
917
|
+
await adapter.softDelete('workspace_files', match.id);
|
|
918
|
+
emit('workspace_files', match.id, 'delete');
|
|
919
|
+
return { ...match, deleted_at: nowIso() };
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
/**
|
|
923
|
+
* @param {{ lessonId: string, fileId?: string|null, runner?: 'browser'|'local', language?: string|null, stdout?: string, stderr?: string, exitCode?: number|null, checks?: any, durationMs?: number|null, revisionOf?: { feedbackTable: string, feedbackId: string }|null }} input
|
|
924
|
+
*/
|
|
925
|
+
export async function saveRunResult({
|
|
926
|
+
lessonId,
|
|
927
|
+
fileId = null,
|
|
928
|
+
runner = 'browser',
|
|
929
|
+
language = null,
|
|
930
|
+
stdout = '',
|
|
931
|
+
stderr = '',
|
|
932
|
+
exitCode = null,
|
|
933
|
+
checks = null,
|
|
934
|
+
durationMs = null,
|
|
935
|
+
revisionOf = null,
|
|
936
|
+
}) {
|
|
937
|
+
const row = stampCreate({
|
|
938
|
+
lesson_id: lessonId,
|
|
939
|
+
file_id: fileId,
|
|
940
|
+
runner,
|
|
941
|
+
language,
|
|
942
|
+
stdout,
|
|
943
|
+
stderr,
|
|
944
|
+
exit_code: exitCode,
|
|
945
|
+
checks_json: checks === null ? null : JSON.stringify(checks),
|
|
946
|
+
duration_ms: durationMs,
|
|
947
|
+
});
|
|
948
|
+
await adapter.put('run_results', row);
|
|
949
|
+
const list = checksList(checks);
|
|
950
|
+
const lesson = lessonId ? await adapter.get('lessons', lessonId) : null;
|
|
951
|
+
const meta =
|
|
952
|
+
list.length > 0
|
|
953
|
+
? {
|
|
954
|
+
runner,
|
|
955
|
+
checks_total: list.length,
|
|
956
|
+
checks_passed: list.filter((c) => c?.passed === true || c?.ok === true).length,
|
|
957
|
+
}
|
|
958
|
+
: { runner, checks_total: list.length, ...(typeof exitCode === 'number' ? { exit_code: exitCode } : {}) };
|
|
959
|
+
await safeRecordEvent('workspace run event', {
|
|
960
|
+
kind: list.length > 0 ? 'exercise_completed' : 'workspace_run',
|
|
961
|
+
surface: 'workspace',
|
|
962
|
+
courseId: lesson?.course_id ?? null,
|
|
963
|
+
lessonId,
|
|
964
|
+
refId: row.id,
|
|
965
|
+
meta,
|
|
966
|
+
});
|
|
967
|
+
await safeMaybeLinkRevision('workspace revision link', {
|
|
968
|
+
surface: 'workspace',
|
|
969
|
+
attemptTable: 'run_results',
|
|
970
|
+
attemptRow: row,
|
|
971
|
+
lessonId,
|
|
972
|
+
revisionOf,
|
|
973
|
+
});
|
|
974
|
+
emit('run_results', row.id, 'create');
|
|
975
|
+
return row;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** @param {string} lessonId */
|
|
979
|
+
export async function listRunResults(lessonId) {
|
|
980
|
+
return adapter.list('run_results', {
|
|
981
|
+
where: { lesson_id: lessonId },
|
|
982
|
+
orderBy: { field: 'created_at', dir: 'asc' },
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/* ------------------------------------------------------------------ capstone (Phase 33) */
|
|
987
|
+
|
|
988
|
+
/** Parse a JSON TEXT column, falling back when it is absent or invalid. */
|
|
989
|
+
function parseJsonColumn(value, fallback) {
|
|
990
|
+
if (value === null || value === undefined || value === '') return fallback;
|
|
991
|
+
try {
|
|
992
|
+
const parsed = JSON.parse(value);
|
|
993
|
+
return parsed === null || parsed === undefined ? fallback : parsed;
|
|
994
|
+
} catch {
|
|
995
|
+
return fallback;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Normalise a stored capstone row: JSON columns become live objects. Rows keep their raw
|
|
1001
|
+
* `*_json` columns too so backups stay byte-identical across adapters.
|
|
1002
|
+
* @param {object|null} row
|
|
1003
|
+
*/
|
|
1004
|
+
export function decodeCapstone(row) {
|
|
1005
|
+
if (!row) return null;
|
|
1006
|
+
return {
|
|
1007
|
+
...row,
|
|
1008
|
+
rubric: parseJsonColumn(row.rubric_json, []),
|
|
1009
|
+
tests: parseJsonColumn(row.tests_json, null),
|
|
1010
|
+
hiddenAnswers: parseJsonColumn(row.hidden_answers_json, null),
|
|
1011
|
+
artifact: parseJsonColumn(row.artifact_json, null),
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
/** @param {object|null} row */
|
|
1016
|
+
export function decodeCapstoneSubmission(row) {
|
|
1017
|
+
if (!row) return null;
|
|
1018
|
+
return {
|
|
1019
|
+
...row,
|
|
1020
|
+
files: parseJsonColumn(row.files_json, null),
|
|
1021
|
+
testResults: parseJsonColumn(row.test_results_json, null),
|
|
1022
|
+
review: parseJsonColumn(row.review_json, null),
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* The capstone for a course, or null. At most one exists — the brief is immutable.
|
|
1028
|
+
* @param {string} courseId
|
|
1029
|
+
* @returns {Promise<object|null>}
|
|
1030
|
+
*/
|
|
1031
|
+
export async function getCapstone(courseId) {
|
|
1032
|
+
const rows = await adapter.list('capstones', {
|
|
1033
|
+
where: { course_id: courseId },
|
|
1034
|
+
orderBy: { field: 'created_at', dir: 'asc' },
|
|
1035
|
+
});
|
|
1036
|
+
return decodeCapstone(rows[0] || null);
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
/**
|
|
1040
|
+
* Create the (immutable) capstone for a course. If one already exists it is returned unchanged —
|
|
1041
|
+
* there is deliberately no update API, so the learner always builds against the original brief.
|
|
1042
|
+
* @param {{ courseId: string, kind?: string, brief_md?: string, rubric?: any[], tests?: any, hiddenAnswers?: any, artifact?: any, externalId?: string|null }} input
|
|
1043
|
+
* @returns {Promise<object>}
|
|
1044
|
+
*/
|
|
1045
|
+
export async function createCapstone({
|
|
1046
|
+
courseId,
|
|
1047
|
+
kind = 'conceptual',
|
|
1048
|
+
brief_md = '',
|
|
1049
|
+
rubric = [],
|
|
1050
|
+
tests = null,
|
|
1051
|
+
hiddenAnswers = null,
|
|
1052
|
+
artifact = null,
|
|
1053
|
+
externalId = null,
|
|
1054
|
+
} = {}) {
|
|
1055
|
+
if (!courseId) throw new Error('createCapstone needs a courseId');
|
|
1056
|
+
const existing = await getCapstone(courseId);
|
|
1057
|
+
if (existing) return existing;
|
|
1058
|
+
const row = stampCreate({
|
|
1059
|
+
course_id: courseId,
|
|
1060
|
+
kind,
|
|
1061
|
+
brief_md,
|
|
1062
|
+
rubric_json: JSON.stringify(Array.isArray(rubric) ? rubric : []),
|
|
1063
|
+
tests_json: tests == null ? null : JSON.stringify(tests),
|
|
1064
|
+
hidden_answers_json: hiddenAnswers == null ? null : JSON.stringify(hiddenAnswers),
|
|
1065
|
+
artifact_json: artifact == null ? null : JSON.stringify(artifact),
|
|
1066
|
+
external_id: externalId,
|
|
1067
|
+
});
|
|
1068
|
+
await adapter.put('capstones', row);
|
|
1069
|
+
emit('capstones', row.id, 'create');
|
|
1070
|
+
return decodeCapstone(row);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Append a submission. Attempts are never overwritten: each call adds a new row with attempt =
|
|
1075
|
+
* max(existing) + 1. When `externalId` matches an existing row the existing one is returned
|
|
1076
|
+
* unchanged (idempotent CLI import).
|
|
1077
|
+
* @param {{ capstoneId: string, content_md?: string, repo_url?: string|null, files?: object|null, testResults?: any, review?: any, verdict?: string|null, externalId?: string|null, revisionOf?: { feedbackTable: string, feedbackId: string }|null }} input
|
|
1078
|
+
* @returns {Promise<object>}
|
|
1079
|
+
*/
|
|
1080
|
+
export async function createCapstoneSubmission({
|
|
1081
|
+
capstoneId,
|
|
1082
|
+
content_md = '',
|
|
1083
|
+
repo_url = null,
|
|
1084
|
+
files = null,
|
|
1085
|
+
testResults = null,
|
|
1086
|
+
review = null,
|
|
1087
|
+
verdict = null,
|
|
1088
|
+
externalId = null,
|
|
1089
|
+
revisionOf = null,
|
|
1090
|
+
} = {}) {
|
|
1091
|
+
if (!capstoneId) throw new Error('createCapstoneSubmission needs a capstoneId');
|
|
1092
|
+
const existing = await adapter.list('capstone_submissions', { where: { capstone_id: capstoneId } });
|
|
1093
|
+
if (externalId) {
|
|
1094
|
+
const match = existing.find((s) => s.external_id === externalId);
|
|
1095
|
+
if (match) return decodeCapstoneSubmission(match);
|
|
1096
|
+
}
|
|
1097
|
+
const attempt = existing.reduce((max, s) => Math.max(max, s.attempt || 0), 0) + 1;
|
|
1098
|
+
const row = stampCreate({
|
|
1099
|
+
capstone_id: capstoneId,
|
|
1100
|
+
attempt,
|
|
1101
|
+
content_md,
|
|
1102
|
+
repo_url,
|
|
1103
|
+
files_json: files == null ? null : JSON.stringify(files),
|
|
1104
|
+
test_results_json: testResults == null ? null : JSON.stringify(testResults),
|
|
1105
|
+
review_json: review == null ? null : JSON.stringify(review),
|
|
1106
|
+
verdict,
|
|
1107
|
+
external_id: externalId,
|
|
1108
|
+
});
|
|
1109
|
+
await adapter.put('capstone_submissions', row);
|
|
1110
|
+
const capstone = await adapter.get('capstones', capstoneId);
|
|
1111
|
+
await safeRecordEvent('capstone checkpoint event', {
|
|
1112
|
+
kind: 'capstone_checkpoint',
|
|
1113
|
+
surface: 'capstone',
|
|
1114
|
+
courseId: capstone?.course_id ?? null,
|
|
1115
|
+
refId: row.id,
|
|
1116
|
+
meta: {
|
|
1117
|
+
source: testResults?.source === 'external' ? 'external' : 'in_app',
|
|
1118
|
+
attempt,
|
|
1119
|
+
},
|
|
1120
|
+
});
|
|
1121
|
+
await safeMaybeLinkRevision('capstone revision link', {
|
|
1122
|
+
surface: 'capstone',
|
|
1123
|
+
attemptTable: 'capstone_submissions',
|
|
1124
|
+
attemptRow: row,
|
|
1125
|
+
capstoneId,
|
|
1126
|
+
revisionOf,
|
|
1127
|
+
});
|
|
1128
|
+
emit('capstone_submissions', row.id, 'create');
|
|
1129
|
+
return decodeCapstoneSubmission(row);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
/**
|
|
1133
|
+
* Every submission for a capstone, oldest attempt first.
|
|
1134
|
+
* @param {string} capstoneId
|
|
1135
|
+
*/
|
|
1136
|
+
export async function listCapstoneSubmissions(capstoneId) {
|
|
1137
|
+
const rows = await adapter.list('capstone_submissions', {
|
|
1138
|
+
where: { capstone_id: capstoneId },
|
|
1139
|
+
orderBy: { field: 'attempt', dir: 'asc' },
|
|
1140
|
+
});
|
|
1141
|
+
return rows.map(decodeCapstoneSubmission);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/** @param {string} capstoneId */
|
|
1145
|
+
export async function getLatestCapstoneSubmission(capstoneId) {
|
|
1146
|
+
const list = await listCapstoneSubmissions(capstoneId);
|
|
1147
|
+
return list[list.length - 1] || null;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/**
|
|
1151
|
+
* Every workspace file for a course, across its lessons, keyed by path (later lessons win).
|
|
1152
|
+
* Used to snapshot the learner's code for a programming capstone submission (CAP-03).
|
|
1153
|
+
* @param {string} courseId
|
|
1154
|
+
* @returns {Promise<Array<{ path: string, content: string, language: string|null }>>}
|
|
1155
|
+
*/
|
|
1156
|
+
export async function listCourseWorkspaceFiles(courseId) {
|
|
1157
|
+
const lessons = await adapter.list('lessons', {
|
|
1158
|
+
where: { course_id: courseId },
|
|
1159
|
+
orderBy: { field: 'position', dir: 'asc' },
|
|
1160
|
+
});
|
|
1161
|
+
const byPath = new Map();
|
|
1162
|
+
for (const lesson of lessons) {
|
|
1163
|
+
const files = await adapter.list('workspace_files', {
|
|
1164
|
+
where: { lesson_id: lesson.id },
|
|
1165
|
+
orderBy: { field: 'path', dir: 'asc' },
|
|
1166
|
+
});
|
|
1167
|
+
for (const file of files) byPath.set(file.path, file);
|
|
1168
|
+
}
|
|
1169
|
+
return [...byPath.values()].map((f) => ({ path: f.path, content: f.content ?? '', language: f.language || null }));
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/* ------------------------------------------------------------------ activity */
|
|
1173
|
+
|
|
1174
|
+
/** @param {string} kind @param {string|null} [refId] */
|
|
1175
|
+
export async function logActivity(kind, refId = null) {
|
|
1176
|
+
const row = stampCreate({ day: dayKey(new Date()), kind, ref_id: refId });
|
|
1177
|
+
await adapter.put('activity', row);
|
|
1178
|
+
emit('activity', row.id, 'create');
|
|
1179
|
+
return row;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
export async function listActivity() {
|
|
1183
|
+
return adapter.list('activity', { orderBy: { field: 'created_at', dir: 'asc' } });
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/* ------------------------------------------------------------------ beta events */
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Create the singleton beta profile row without activating it (D-08).
|
|
1190
|
+
* @returns {Promise<object>}
|
|
1191
|
+
*/
|
|
1192
|
+
export async function getBetaProfile() {
|
|
1193
|
+
const existing = await adapter.get('beta_profile', 'local');
|
|
1194
|
+
if (existing) return existing;
|
|
1195
|
+
const row = stampCreate({ id: 'local', participant_id: uuid(), activated_at: null, activated_day: null });
|
|
1196
|
+
await adapter.put('beta_profile', row);
|
|
1197
|
+
return row;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Activate the singleton profile on its first meaningful event, never rewriting it afterwards.
|
|
1202
|
+
* @param {object} event a stored `learning_events` row
|
|
1203
|
+
*/
|
|
1204
|
+
async function ensureBetaProfile(event) {
|
|
1205
|
+
const existing = await adapter.get('beta_profile', 'local');
|
|
1206
|
+
if (!existing) {
|
|
1207
|
+
return adapter.put(
|
|
1208
|
+
'beta_profile',
|
|
1209
|
+
stampCreate(
|
|
1210
|
+
{ id: 'local', participant_id: uuid(), activated_at: event.created_at, activated_day: event.day },
|
|
1211
|
+
event.created_at
|
|
1212
|
+
)
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
if (existing.activated_at == null) {
|
|
1216
|
+
return adapter.put(
|
|
1217
|
+
'beta_profile',
|
|
1218
|
+
stampUpdate(existing, { activated_at: event.created_at, activated_day: event.day })
|
|
1219
|
+
);
|
|
1220
|
+
}
|
|
1221
|
+
return existing;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Record one meaningful learning event (D-01..D-05).
|
|
1226
|
+
* @param {{ kind: string, surface: string, courseId?: string|null, lessonId?: string|null, refId?: string|null, meta?: object, now?: Date|string }} input
|
|
1227
|
+
* @returns {Promise<object>} the stored event row
|
|
1228
|
+
*/
|
|
1229
|
+
export async function recordEvent({
|
|
1230
|
+
kind,
|
|
1231
|
+
surface,
|
|
1232
|
+
courseId = null,
|
|
1233
|
+
lessonId = null,
|
|
1234
|
+
refId = null,
|
|
1235
|
+
meta = {},
|
|
1236
|
+
now = new Date(),
|
|
1237
|
+
} = {}) {
|
|
1238
|
+
validateEventKind(kind);
|
|
1239
|
+
validateEventSurface(kind, surface);
|
|
1240
|
+
const compact = compactMeta(meta);
|
|
1241
|
+
validateMeta(kind, compact);
|
|
1242
|
+
const at = now instanceof Date ? now : new Date(now);
|
|
1243
|
+
const row = buildEventRow({ kind, surface, courseId, lessonId, refId, meta: compact, now: at });
|
|
1244
|
+
const event = stampCreate(row, at.toISOString());
|
|
1245
|
+
await adapter.put('learning_events', event);
|
|
1246
|
+
await ensureBetaProfile(event);
|
|
1247
|
+
emit('learning_events', event.id, 'create');
|
|
1248
|
+
return event;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* List meaningful events, oldest first. Unknown filter keys are passed to the adapter so its
|
|
1253
|
+
* indexed-field guard rejects them (adapter parity, P4).
|
|
1254
|
+
* @param {{ kind?: string, day?: string, surface?: string, courseId?: string, lessonId?: string, includeDeleted?: boolean }} [query]
|
|
1255
|
+
* @returns {Promise<object[]>}
|
|
1256
|
+
*/
|
|
1257
|
+
export async function listEvents({ kind, day, surface, courseId, lessonId, includeDeleted, ...rest } = {}) {
|
|
1258
|
+
const where = {};
|
|
1259
|
+
if (kind != null) where.kind = kind;
|
|
1260
|
+
if (day != null) where.day = day;
|
|
1261
|
+
if (surface != null) where.surface = surface;
|
|
1262
|
+
if (courseId != null) where.course_id = courseId;
|
|
1263
|
+
if (lessonId != null) where.lesson_id = lessonId;
|
|
1264
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
1265
|
+
if (value !== undefined) where[key] = value;
|
|
1266
|
+
}
|
|
1267
|
+
const opts = { where, orderBy: { field: 'created_at', dir: 'asc' } };
|
|
1268
|
+
if (includeDeleted !== undefined) opts.includeDeleted = includeDeleted;
|
|
1269
|
+
return adapter.list('learning_events', opts);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/**
|
|
1273
|
+
* Read revision links through indexed equality filters only (D-06, adapter parity).
|
|
1274
|
+
* Unknown filter keys are forwarded so `assertIndexedField` rejects them.
|
|
1275
|
+
* @param {{ surface?: string, courseId?: string, lessonId?: string, attemptId?: string, revisedId?: string, linkKind?: string, includeDeleted?: boolean }} [query]
|
|
1276
|
+
* @returns {Promise<object[]>}
|
|
1277
|
+
*/
|
|
1278
|
+
export async function listRevisionLinks({
|
|
1279
|
+
surface,
|
|
1280
|
+
courseId,
|
|
1281
|
+
lessonId,
|
|
1282
|
+
attemptId,
|
|
1283
|
+
revisedId,
|
|
1284
|
+
linkKind,
|
|
1285
|
+
includeDeleted,
|
|
1286
|
+
...rest
|
|
1287
|
+
} = {}) {
|
|
1288
|
+
const where = {};
|
|
1289
|
+
if (surface != null) where.surface = surface;
|
|
1290
|
+
if (courseId != null) where.course_id = courseId;
|
|
1291
|
+
if (lessonId != null) where.lesson_id = lessonId;
|
|
1292
|
+
if (attemptId != null) where.attempt_id = attemptId;
|
|
1293
|
+
if (revisedId != null) where.revised_id = revisedId;
|
|
1294
|
+
if (linkKind != null) where.link_kind = linkKind;
|
|
1295
|
+
for (const [key, value] of Object.entries(rest)) {
|
|
1296
|
+
if (value !== undefined) where[key] = value;
|
|
1297
|
+
}
|
|
1298
|
+
const opts = { where, orderBy: { field: 'created_at', dir: 'asc' } };
|
|
1299
|
+
if (includeDeleted !== undefined) opts.includeDeleted = includeDeleted;
|
|
1300
|
+
return adapter.list('revision_links', opts);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
/* ------------------------------------------------------------------ portability */
|
|
1304
|
+
|
|
1305
|
+
/** Full backup — settings/API keys never live in the repository, so they are never exported. */
|
|
1306
|
+
export async function exportAll() {
|
|
1307
|
+
return adapter.exportAll();
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/** @param {object} dump */
|
|
1311
|
+
export async function importAll(dump) {
|
|
1312
|
+
const result = await adapter.importAll(dump);
|
|
1313
|
+
emit('*', null, 'import');
|
|
1314
|
+
return result;
|
|
1315
|
+
}
|