@researai/deepscientist 1.5.16 → 1.6.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 (896) hide show
  1. package/AGENTS.md +309 -130
  2. package/AISB/catalog/aisb.b1.agentic_coding.yaml +244 -0
  3. package/AISB/catalog/aisb.b10.climate_earth.yaml +235 -0
  4. package/AISB/catalog/aisb.b11.model_efficiency.yaml +231 -0
  5. package/AISB/catalog/aisb.b12.embodied_ai.yaml +238 -0
  6. package/AISB/catalog/aisb.b2.agent_systems.yaml +229 -0
  7. package/AISB/catalog/aisb.b3.self_evolving_rl.yaml +237 -0
  8. package/AISB/catalog/aisb.b4.lm_reasoning.yaml +240 -0
  9. package/AISB/catalog/aisb.b5.math_proof.yaml +235 -0
  10. package/AISB/catalog/aisb.b6.research_process.yaml +243 -0
  11. package/AISB/catalog/aisb.b7.multimodal_fusion.yaml +232 -0
  12. package/AISB/catalog/aisb.b8.lifesci_drug.yaml +275 -0
  13. package/AISB/catalog/aisb.b9.material_science.yaml +237 -0
  14. package/AISB/catalog/aisb.t3.001_savvy.yaml +159 -0
  15. package/AISB/catalog/aisb.t3.001_savvy.zh.yaml +121 -0
  16. package/AISB/catalog/aisb.t3.002_pinet.yaml +189 -0
  17. package/AISB/catalog/aisb.t3.002_pinet.zh.yaml +130 -0
  18. package/AISB/catalog/aisb.t3.004_decentralattn.yaml +184 -0
  19. package/AISB/catalog/aisb.t3.004_decentralattn.zh.yaml +153 -0
  20. package/AISB/catalog/aisb.t3.005_tsae.yaml +193 -0
  21. package/AISB/catalog/aisb.t3.005_tsae.zh.yaml +139 -0
  22. package/AISB/catalog/aisb.t3.006_physense.yaml +194 -0
  23. package/AISB/catalog/aisb.t3.006_physense.zh.yaml +118 -0
  24. package/AISB/catalog/aisb.t3.007_reasoningiqa.yaml +169 -0
  25. package/AISB/catalog/aisb.t3.007_reasoningiqa.zh.yaml +133 -0
  26. package/AISB/catalog/aisb.t3.008_meanflows.yaml +188 -0
  27. package/AISB/catalog/aisb.t3.008_meanflows.zh.yaml +140 -0
  28. package/AISB/catalog/aisb.t3.009_scoremissing.yaml +179 -0
  29. package/AISB/catalog/aisb.t3.009_scoremissing.zh.yaml +119 -0
  30. package/AISB/catalog/aisb.t3.010_suitabilityfilter.yaml +221 -0
  31. package/AISB/catalog/aisb.t3.010_suitabilityfilter.zh.yaml +141 -0
  32. package/AISB/catalog/aisb.t3.011_osd.yaml +206 -0
  33. package/AISB/catalog/aisb.t3.011_osd.zh.yaml +163 -0
  34. package/AISB/catalog/aisb.t3.012_efficientqat.yaml +206 -0
  35. package/AISB/catalog/aisb.t3.012_efficientqat.zh.yaml +159 -0
  36. package/AISB/catalog/aisb.t3.013_appl.yaml +152 -0
  37. package/AISB/catalog/aisb.t3.013_appl.zh.yaml +126 -0
  38. package/AISB/catalog/aisb.t3.014_piguard.yaml +207 -0
  39. package/AISB/catalog/aisb.t3.014_piguard.zh.yaml +164 -0
  40. package/AISB/catalog/aisb.t3.015_frspec.yaml +209 -0
  41. package/AISB/catalog/aisb.t3.015_frspec.zh.yaml +163 -0
  42. package/AISB/catalog/aisb.t3.016_mathfusion.yaml +166 -0
  43. package/AISB/catalog/aisb.t3.016_mathfusion.zh.yaml +145 -0
  44. package/AISB/catalog/aisb.t3.017_multimodalglp.yaml +171 -0
  45. package/AISB/catalog/aisb.t3.017_multimodalglp.zh.yaml +122 -0
  46. package/AISB/catalog/aisb.t3.018_cotsynth.yaml +206 -0
  47. package/AISB/catalog/aisb.t3.018_cotsynth.zh.yaml +162 -0
  48. package/AISB/catalog/aisb.t3.019_dyscaleut.yaml +211 -0
  49. package/AISB/catalog/aisb.t3.019_dyscaleut.zh.yaml +148 -0
  50. package/AISB/catalog/aisb.t3.020_aristotle.yaml +173 -0
  51. package/AISB/catalog/aisb.t3.020_aristotle.zh.yaml +119 -0
  52. package/AISB/catalog/aisb.t3.021_tokenrecycling.yaml +160 -0
  53. package/AISB/catalog/aisb.t3.021_tokenrecycling.zh.yaml +129 -0
  54. package/AISB/catalog/aisb.t3.022_chainofreasoning.yaml +204 -0
  55. package/AISB/catalog/aisb.t3.022_chainofreasoning.zh.yaml +161 -0
  56. package/AISB/catalog/aisb.t3.023_guidedembed.yaml +211 -0
  57. package/AISB/catalog/aisb.t3.023_guidedembed.zh.yaml +189 -0
  58. package/AISB/catalog/aisb.t3.024_outputcentric.yaml +148 -0
  59. package/AISB/catalog/aisb.t3.024_outputcentric.zh.yaml +131 -0
  60. package/AISB/catalog/aisb.t3.025_deeper.yaml +143 -0
  61. package/AISB/catalog/aisb.t3.025_deeper.zh.yaml +116 -0
  62. package/AISB/catalog/aisb.t3.026_gartkg.yaml +195 -0
  63. package/AISB/catalog/aisb.t3.026_gartkg.zh.yaml +127 -0
  64. package/AISB/catalog/aisb.t3.027_citeeval.yaml +182 -0
  65. package/AISB/catalog/aisb.t3.027_citeeval.zh.yaml +135 -0
  66. package/AISB/catalog/aisb.t3.028_sbam.yaml +206 -0
  67. package/AISB/catalog/aisb.t3.028_sbam.zh.yaml +166 -0
  68. package/AISB/catalog/aisb.t3.029_cdqgeoembed.yaml +224 -0
  69. package/AISB/catalog/aisb.t3.029_cdqgeoembed.zh.yaml +142 -0
  70. package/AISB/catalog/aisb.t3.030_processrm.yaml +211 -0
  71. package/AISB/catalog/aisb.t3.030_processrm.zh.yaml +166 -0
  72. package/AISB/catalog/aisb.t3.031_circuitstability.yaml +172 -0
  73. package/AISB/catalog/aisb.t3.031_circuitstability.zh.yaml +134 -0
  74. package/AISB/catalog/aisb.t3.032_ptsolver.yaml +169 -0
  75. package/AISB/catalog/aisb.t3.032_ptsolver.zh.yaml +135 -0
  76. package/AISB/catalog/aisb.t3.033_gcse.yaml +144 -0
  77. package/AISB/catalog/aisb.t3.033_gcse.zh.yaml +126 -0
  78. package/AISB/catalog/aisb.t3.034_ensemblewm.yaml +183 -0
  79. package/AISB/catalog/aisb.t3.034_ensemblewm.zh.yaml +146 -0
  80. package/AISB/catalog/aisb.t3.035_moralvalueswa.yaml +207 -0
  81. package/AISB/catalog/aisb.t3.035_moralvalueswa.zh.yaml +165 -0
  82. package/AISB/catalog/aisb.t3.036_weakstrongpref.yaml +210 -0
  83. package/AISB/catalog/aisb.t3.036_weakstrongpref.zh.yaml +194 -0
  84. package/AISB/catalog/aisb.t3.037_dementiamask.yaml +172 -0
  85. package/AISB/catalog/aisb.t3.037_dementiamask.zh.yaml +132 -0
  86. package/AISB/catalog/aisb.t3.038_tinysam.yaml +284 -0
  87. package/AISB/catalog/aisb.t3.038_tinysam.zh.yaml +240 -0
  88. package/AISB/catalog/aisb.t3.039_calf.yaml +224 -0
  89. package/AISB/catalog/aisb.t3.039_calf.zh.yaml +194 -0
  90. package/AISB/catalog/aisb.t3.040_graniteguardian.yaml +199 -0
  91. package/AISB/catalog/aisb.t3.040_graniteguardian.zh.yaml +174 -0
  92. package/AISB/catalog/aisb.t3.041_amdm.yaml +149 -0
  93. package/AISB/catalog/aisb.t3.041_amdm.zh.yaml +137 -0
  94. package/AISB/catalog/aisb.t3.042_xpatch.yaml +216 -0
  95. package/AISB/catalog/aisb.t3.042_xpatch.zh.yaml +182 -0
  96. package/AISB/catalog/aisb.t3.043_vhm.yaml +268 -0
  97. package/AISB/catalog/aisb.t3.043_vhm.zh.yaml +193 -0
  98. package/AISB/catalog/aisb.t3.044_rgvi.yaml +224 -0
  99. package/AISB/catalog/aisb.t3.044_rgvi.zh.yaml +176 -0
  100. package/AISB/catalog/aisb.t3.045_pslstm.yaml +203 -0
  101. package/AISB/catalog/aisb.t3.045_pslstm.zh.yaml +179 -0
  102. package/AISB/catalog/aisb.t3.046_nonstatts.yaml +208 -0
  103. package/AISB/catalog/aisb.t3.046_nonstatts.zh.yaml +194 -0
  104. package/AISB/catalog/aisb.t3.047_timepfn.yaml +156 -0
  105. package/AISB/catalog/aisb.t3.047_timepfn.zh.yaml +124 -0
  106. package/AISB/catalog/aisb.t3.048_proxyspex.yaml +148 -0
  107. package/AISB/catalog/aisb.t3.048_proxyspex.zh.yaml +125 -0
  108. package/AISB/catalog/aisb.t3.049_hogwildinference.yaml +183 -0
  109. package/AISB/catalog/aisb.t3.049_hogwildinference.zh.yaml +138 -0
  110. package/AISB/catalog/aisb.t3.050_causalpfn.yaml +214 -0
  111. package/AISB/catalog/aisb.t3.050_causalpfn.zh.yaml +190 -0
  112. package/AISB/catalog/aisb.t3.051_flashtp.yaml +169 -0
  113. package/AISB/catalog/aisb.t3.051_flashtp.zh.yaml +124 -0
  114. package/AISB/catalog/aisb.t3.052_nsdiff.yaml +155 -0
  115. package/AISB/catalog/aisb.t3.052_nsdiff.zh.yaml +138 -0
  116. package/AISB/catalog/aisb.t3.053_k2vae.yaml +158 -0
  117. package/AISB/catalog/aisb.t3.053_k2vae.zh.yaml +132 -0
  118. package/AISB/catalog/aisb.t3.054_timebase.yaml +178 -0
  119. package/AISB/catalog/aisb.t3.054_timebase.zh.yaml +158 -0
  120. package/AISB/catalog/aisb.t3.055_csbrain.yaml +238 -0
  121. package/AISB/catalog/aisb.t3.055_csbrain.zh.yaml +184 -0
  122. package/AISB/catalog/aisb.t3.056_infosam.yaml +224 -0
  123. package/AISB/catalog/aisb.t3.056_infosam.zh.yaml +189 -0
  124. package/AISB/catalog/aisb.t3.057_mdreid.yaml +129 -0
  125. package/AISB/catalog/aisb.t3.057_mdreid.zh.yaml +117 -0
  126. package/AISB/catalog/aisb.t3.058_mindglitch.yaml +171 -0
  127. package/AISB/catalog/aisb.t3.058_mindglitch.zh.yaml +145 -0
  128. package/AISB/catalog/aisb.t3.059_selfsupervised.yaml +154 -0
  129. package/AISB/catalog/aisb.t3.059_selfsupervised.zh.yaml +125 -0
  130. package/AISB/catalog/aisb.t3.060_iaggad.yaml +121 -0
  131. package/AISB/catalog/aisb.t3.060_iaggad.zh.yaml +100 -0
  132. package/AISB/catalog/aisb.t3.061_hsgkn.yaml +136 -0
  133. package/AISB/catalog/aisb.t3.061_hsgkn.zh.yaml +113 -0
  134. package/AISB/catalog/aisb.t3.062_visionts.yaml +237 -0
  135. package/AISB/catalog/aisb.t3.062_visionts.zh.yaml +216 -0
  136. package/AISB/catalog/aisb.t3.063_tsrag.yaml +162 -0
  137. package/AISB/catalog/aisb.t3.063_tsrag.zh.yaml +138 -0
  138. package/AISB/catalog/aisb.t3.064_pir.yaml +221 -0
  139. package/AISB/catalog/aisb.t3.064_pir.zh.yaml +197 -0
  140. package/AISB/catalog/aisb.t3.065_proteinbinding.yaml +234 -0
  141. package/AISB/catalog/aisb.t3.065_proteinbinding.zh.yaml +167 -0
  142. package/AISB/catalog/aisb.t3.066_tropicalattention.yaml +267 -0
  143. package/AISB/catalog/aisb.t3.066_tropicalattention.zh.yaml +229 -0
  144. package/AISB/catalog/aisb.t3.067_kanad.yaml +193 -0
  145. package/AISB/catalog/aisb.t3.067_kanad.zh.yaml +167 -0
  146. package/AISB/catalog/aisb.t3.068_sempo.yaml +187 -0
  147. package/AISB/catalog/aisb.t3.068_sempo.zh.yaml +148 -0
  148. package/AISB/catalog/aisb.t3.069_treehfd.yaml +129 -0
  149. package/AISB/catalog/aisb.t3.069_treehfd.zh.yaml +111 -0
  150. package/AISB/catalog/aisb.t3.070_certifiedunlearning.yaml +224 -0
  151. package/AISB/catalog/aisb.t3.070_certifiedunlearning.zh.yaml +171 -0
  152. package/AISB/catalog/aisb.t3.071_neuralmjd.yaml +142 -0
  153. package/AISB/catalog/aisb.t3.071_neuralmjd.zh.yaml +120 -0
  154. package/AISB/catalog/aisb.t3.072_fedgmt.yaml +181 -0
  155. package/AISB/catalog/aisb.t3.072_fedgmt.zh.yaml +158 -0
  156. package/AISB/catalog/aisb.t3.073_rld.yaml +161 -0
  157. package/AISB/catalog/aisb.t3.073_rld.zh.yaml +129 -0
  158. package/AISB/catalog/aisb.t3.074_lsvi.yaml +163 -0
  159. package/AISB/catalog/aisb.t3.074_lsvi.zh.yaml +129 -0
  160. package/AISB/catalog/aisb.t3.075_treeslicedentropy.yaml +201 -0
  161. package/AISB/catalog/aisb.t3.075_treeslicedentropy.zh.yaml +148 -0
  162. package/AISB/catalog/aisb.t3.076_aanet.yaml +169 -0
  163. package/AISB/catalog/aisb.t3.076_aanet.zh.yaml +129 -0
  164. package/AISB/catalog/aisb.t3.077_cmnn.yaml +199 -0
  165. package/AISB/catalog/aisb.t3.077_cmnn.zh.yaml +165 -0
  166. package/AISB/catalog/aisb.t3.078_conformalanomaly.yaml +146 -0
  167. package/AISB/catalog/aisb.t3.078_conformalanomaly.zh.yaml +117 -0
  168. package/AISB/catalog/aisb.t3.079_dpfkmeans.yaml +131 -0
  169. package/AISB/catalog/aisb.t3.079_dpfkmeans.zh.yaml +104 -0
  170. package/AISB/catalog/aisb.t3.080_latentscorereweight.yaml +169 -0
  171. package/AISB/catalog/aisb.t3.080_latentscorereweight.zh.yaml +123 -0
  172. package/AISB/catalog/aisb.t3.081_qmamba.yaml +150 -0
  173. package/AISB/catalog/aisb.t3.081_qmamba.zh.yaml +117 -0
  174. package/AISB/catalog/aisb.t3.082_onlinellmrouting.yaml +160 -0
  175. package/AISB/catalog/aisb.t3.082_onlinellmrouting.zh.yaml +133 -0
  176. package/AISB/catalog/aisb.t3.083_starformer.yaml +178 -0
  177. package/AISB/catalog/aisb.t3.083_starformer.zh.yaml +140 -0
  178. package/AISB/catalog/aisb.t3.084_ift.yaml +139 -0
  179. package/AISB/catalog/aisb.t3.084_ift.zh.yaml +111 -0
  180. package/AISB/catalog/aisb.t3.085_neuralsurv.yaml +183 -0
  181. package/AISB/catalog/aisb.t3.085_neuralsurv.zh.yaml +143 -0
  182. package/AISB/catalog/aisb.t3.086_stella.yaml +197 -0
  183. package/AISB/catalog/aisb.t3.086_stella.zh.yaml +142 -0
  184. package/AISB/catalog/aisb.t3.087_moses.yaml +167 -0
  185. package/AISB/catalog/aisb.t3.087_moses.zh.yaml +132 -0
  186. package/AISB/catalog/aisb.t3.088_channelnorm.yaml +140 -0
  187. package/AISB/catalog/aisb.t3.088_channelnorm.zh.yaml +109 -0
  188. package/AISB/catalog/aisb.t3.089_causalvelocity.yaml +730 -0
  189. package/AISB/catalog/aisb.t3.089_causalvelocity.zh.yaml +668 -0
  190. package/AISB/catalog/aisb.t3.090_rstib.yaml +144 -0
  191. package/AISB/catalog/aisb.t3.090_rstib.zh.yaml +109 -0
  192. package/AISB/catalog/aisb.t3.091_timeawarecausal.yaml +132 -0
  193. package/AISB/catalog/aisb.t3.091_timeawarecausal.zh.yaml +107 -0
  194. package/AISB/catalog/aisb.t3.092_kmeanslocalopt.yaml +138 -0
  195. package/AISB/catalog/aisb.t3.092_kmeanslocalopt.zh.yaml +110 -0
  196. package/AISB/catalog/aisb.t3.093_fedwmsam.yaml +134 -0
  197. package/AISB/catalog/aisb.t3.093_fedwmsam.zh.yaml +106 -0
  198. package/AISB/catalog/aisb.t3.094_boundre.yaml +147 -0
  199. package/AISB/catalog/aisb.t3.094_boundre.zh.yaml +114 -0
  200. package/AISB/catalog/aisb.t3.095_fastfeaturecp.yaml +153 -0
  201. package/AISB/catalog/aisb.t3.095_fastfeaturecp.zh.yaml +118 -0
  202. package/AISB/catalog/aisb.t3.096_m3svm.yaml +189 -0
  203. package/AISB/catalog/aisb.t3.096_m3svm.zh.yaml +149 -0
  204. package/AISB/catalog/aisb.t3.097_wassersteintl.yaml +212 -0
  205. package/AISB/catalog/aisb.t3.097_wassersteintl.zh.yaml +169 -0
  206. package/AISB/catalog/aisb.t3.098_xmahalanobis.yaml +171 -0
  207. package/AISB/catalog/aisb.t3.098_xmahalanobis.zh.yaml +127 -0
  208. package/AISB/catalog/aisb.t3.099_ollalanding.yaml +248 -0
  209. package/AISB/catalog/aisb.t3.099_ollalanding.zh.yaml +182 -0
  210. package/AISB/catalog/aisb.t3.100_invmissingdata.yaml +179 -0
  211. package/AISB/catalog/aisb.t3.100_invmissingdata.zh.yaml +150 -0
  212. package/AISB/catalog/aisb.t3.101_acia.yaml +164 -0
  213. package/AISB/catalog/aisb.t3.101_acia.zh.yaml +109 -0
  214. package/AISB/catalog/aisb.t3.102_stochasticff.yaml +178 -0
  215. package/AISB/catalog/aisb.t3.102_stochasticff.zh.yaml +130 -0
  216. package/AISB/catalog/aisb.t3.103_qdcp.yaml +150 -0
  217. package/AISB/catalog/aisb.t3.103_qdcp.zh.yaml +116 -0
  218. package/AISB/catalog/aisb.t3.104_balancedactiveinf.yaml +137 -0
  219. package/AISB/catalog/aisb.t3.104_balancedactiveinf.zh.yaml +104 -0
  220. package/AISB/catalog/aisb.t3.105_binaryclasseval.yaml +161 -0
  221. package/AISB/catalog/aisb.t3.105_binaryclasseval.zh.yaml +130 -0
  222. package/AISB/image/001_aisb.t3.001_savvy.jpg +0 -0
  223. package/AISB/image/002_aisb.t3.002_pinet.jpg +0 -0
  224. package/AISB/image/003_aisb.t3.003_dmsqd.jpg +0 -0
  225. package/AISB/image/004_aisb.t3.004_decentralattn.jpg +0 -0
  226. package/AISB/image/005_aisb.t3.005_tsae.jpg +0 -0
  227. package/AISB/image/006_aisb.t3.006_physense.jpg +0 -0
  228. package/AISB/image/007_aisb.t3.007_reasoningiqa.jpg +0 -0
  229. package/AISB/image/008_aisb.t3.008_meanflows.jpg +0 -0
  230. package/AISB/image/009_aisb.t3.009_scoremissing.jpg +0 -0
  231. package/AISB/image/010_aisb.t3.010_suitabilityfilter.jpg +0 -0
  232. package/AISB/image/011_aisb.t3.011_osd.jpg +0 -0
  233. package/AISB/image/012_aisb.t3.012_efficientqat.jpg +0 -0
  234. package/AISB/image/013_aisb.t3.013_appl.jpg +0 -0
  235. package/AISB/image/014_aisb.t3.014_piguard.jpg +0 -0
  236. package/AISB/image/015_aisb.t3.015_frspec.jpg +0 -0
  237. package/AISB/image/016_aisb.t3.016_mathfusion.jpg +0 -0
  238. package/AISB/image/017_aisb.t3.017_multimodalglp.jpg +0 -0
  239. package/AISB/image/018_aisb.t3.018_cotsynth.jpg +0 -0
  240. package/AISB/image/019_aisb.t3.019_dyscaleut.jpg +0 -0
  241. package/AISB/image/020_aisb.t3.020_aristotle.jpg +0 -0
  242. package/AISB/image/021_aisb.t3.021_tokenrecycling.jpg +0 -0
  243. package/AISB/image/022_aisb.t3.022_chainofreasoning.jpg +0 -0
  244. package/AISB/image/023_aisb.t3.023_guidedembed.jpg +0 -0
  245. package/AISB/image/024_aisb.t3.024_outputcentric.jpg +0 -0
  246. package/AISB/image/025_aisb.t3.025_deeper.jpg +0 -0
  247. package/AISB/image/026_aisb.t3.026_gartkg.jpg +0 -0
  248. package/AISB/image/027_aisb.t3.027_citeeval.jpg +0 -0
  249. package/AISB/image/028_aisb.t3.028_sbam.jpg +0 -0
  250. package/AISB/image/029_aisb.t3.029_cdqgeoembed.jpg +0 -0
  251. package/AISB/image/030_aisb.t3.030_processrm.jpg +0 -0
  252. package/AISB/image/031_aisb.t3.031_circuitstability.jpg +0 -0
  253. package/AISB/image/032_aisb.t3.032_ptsolver.jpg +0 -0
  254. package/AISB/image/033_aisb.t3.033_gcse.jpg +0 -0
  255. package/AISB/image/034_aisb.t3.034_ensemblewm.jpg +0 -0
  256. package/AISB/image/035_aisb.t3.035_moralvalueswa.jpg +0 -0
  257. package/AISB/image/036_aisb.t3.036_weakstrongpref.jpg +0 -0
  258. package/AISB/image/037_aisb.t3.037_dementiamask.jpg +0 -0
  259. package/AISB/image/038_aisb.t3.038_tinysam.jpg +0 -0
  260. package/AISB/image/039_aisb.t3.039_calf.jpg +0 -0
  261. package/AISB/image/040_aisb.t3.040_graniteguardian.jpg +0 -0
  262. package/AISB/image/041_aisb.t3.041_amdm.jpg +0 -0
  263. package/AISB/image/042_aisb.t3.042_xpatch.jpg +0 -0
  264. package/AISB/image/043_aisb.t3.043_vhm.jpg +0 -0
  265. package/AISB/image/044_aisb.t3.044_rgvi.jpg +0 -0
  266. package/AISB/image/045_aisb.t3.045_pslstm.jpg +0 -0
  267. package/AISB/image/046_aisb.t3.046_nonstatts.jpg +0 -0
  268. package/AISB/image/047_aisb.t3.047_timepfn.jpg +0 -0
  269. package/AISB/image/048_aisb.t3.048_proxyspex.jpg +0 -0
  270. package/AISB/image/049_aisb.t3.049_hogwildinference.jpg +0 -0
  271. package/AISB/image/050_aisb.t3.050_causalpfn.jpg +0 -0
  272. package/AISB/image/051_aisb.t3.051_flashtp.jpg +0 -0
  273. package/AISB/image/052_aisb.t3.052_nsdiff.jpg +0 -0
  274. package/AISB/image/053_aisb.t3.053_k2vae.jpg +0 -0
  275. package/AISB/image/054_aisb.t3.054_timebase.jpg +0 -0
  276. package/AISB/image/055_aisb.t3.055_csbrain.jpg +0 -0
  277. package/AISB/image/056_aisb.t3.056_infosam.jpg +0 -0
  278. package/AISB/image/057_aisb.t3.057_mdreid.jpg +0 -0
  279. package/AISB/image/058_aisb.t3.058_mindglitch.jpg +0 -0
  280. package/AISB/image/059_aisb.t3.059_selfsupervised.jpg +0 -0
  281. package/AISB/image/060_aisb.t3.060_iaggad.jpg +0 -0
  282. package/AISB/image/061_aisb.t3.061_hsgkn.jpg +0 -0
  283. package/AISB/image/062_aisb.t3.062_visionts.jpg +0 -0
  284. package/AISB/image/063_aisb.t3.063_tsrag.jpg +0 -0
  285. package/AISB/image/064_aisb.t3.064_pir.jpg +0 -0
  286. package/AISB/image/065_aisb.t3.065_proteinbinding.jpg +0 -0
  287. package/AISB/image/066_aisb.t3.066_tropicalattention.jpg +0 -0
  288. package/AISB/image/067_aisb.t3.067_kanad.jpg +0 -0
  289. package/AISB/image/068_aisb.t3.068_sempo.jpg +0 -0
  290. package/AISB/image/069_aisb.t3.069_treehfd.jpg +0 -0
  291. package/AISB/image/070_aisb.t3.070_certifiedunlearning.jpg +0 -0
  292. package/AISB/image/071_aisb.t3.071_neuralmjd.jpg +0 -0
  293. package/AISB/image/072_aisb.t3.072_fedgmt.jpg +0 -0
  294. package/AISB/image/073_aisb.t3.073_rld.jpg +0 -0
  295. package/AISB/image/074_aisb.t3.074_lsvi.jpg +0 -0
  296. package/AISB/image/075_aisb.t3.075_treeslicedentropy.jpg +0 -0
  297. package/AISB/image/076_aisb.t3.076_aanet.jpg +0 -0
  298. package/AISB/image/077_aisb.t3.077_cmnn.jpg +0 -0
  299. package/AISB/image/078_aisb.t3.078_conformalanomaly.jpg +0 -0
  300. package/AISB/image/079_aisb.t3.079_dpfkmeans.jpg +0 -0
  301. package/AISB/image/080_aisb.t3.080_latentscorereweight.jpg +0 -0
  302. package/AISB/image/081_aisb.t3.081_qmamba.jpg +0 -0
  303. package/AISB/image/082_aisb.t3.082_onlinellmrouting.jpg +0 -0
  304. package/AISB/image/083_aisb.t3.083_starformer.jpg +0 -0
  305. package/AISB/image/084_aisb.t3.084_ift.jpg +0 -0
  306. package/AISB/image/085_aisb.t3.085_neuralsurv.jpg +0 -0
  307. package/AISB/image/086_aisb.t3.086_stella.jpg +0 -0
  308. package/AISB/image/087_aisb.t3.087_moses.jpg +0 -0
  309. package/AISB/image/088_aisb.t3.088_channelnorm.jpg +0 -0
  310. package/AISB/image/089_aisb.t3.089_causalvelocity.jpg +0 -0
  311. package/AISB/image/090_aisb.t3.090_rstib.jpg +0 -0
  312. package/AISB/image/091_aisb.t3.091_timeawarecausal.jpg +0 -0
  313. package/AISB/image/092_aisb.t3.092_kmeanslocalopt.jpg +0 -0
  314. package/AISB/image/093_aisb.t3.093_fedwmsam.jpg +0 -0
  315. package/AISB/image/094_aisb.t3.094_boundre.jpg +0 -0
  316. package/AISB/image/095_aisb.t3.095_fastfeaturecp.jpg +0 -0
  317. package/AISB/image/096_aisb.t3.096_m3svm.jpg +0 -0
  318. package/AISB/image/097_aisb.t3.097_wassersteintl.jpg +0 -0
  319. package/AISB/image/098_aisb.t3.098_xmahalanobis.jpg +0 -0
  320. package/AISB/image/099_aisb.t3.099_ollalanding.jpg +0 -0
  321. package/AISB/image/100_aisb.t3.100_invmissingdata.jpg +0 -0
  322. package/AISB/image/101_aisb.t3.101_acia.jpg +0 -0
  323. package/AISB/image/102_aisb.t3.102_stochasticff.jpg +0 -0
  324. package/AISB/image/103_aisb.t3.103_qdcp.jpg +0 -0
  325. package/AISB/image/104_aisb.t3.104_balancedactiveinf.jpg +0 -0
  326. package/AISB/image/105_aisb.t3.105_binaryclasseval.jpg +0 -0
  327. package/AISB/image/106_aisb.t1.reasoning_lite.jpg +0 -0
  328. package/AISB/image/107_aisb.t2.paper_audit.jpg +0 -0
  329. package/AISB/image/108_aisb.t3.multi_gpu_search.jpg +0 -0
  330. package/AISB/image/109_aisb.t3.tdc_admet.jpg +0 -0
  331. package/AISB/image/aisb.b1.agentic_coding.svg +16 -0
  332. package/AISB/image/aisb.b10.climate_earth.svg +16 -0
  333. package/AISB/image/aisb.b11.model_efficiency.svg +16 -0
  334. package/AISB/image/aisb.b12.embodied_ai.svg +16 -0
  335. package/AISB/image/aisb.b2.agent_systems.svg +16 -0
  336. package/AISB/image/aisb.b3.self_evolving_rl.svg +16 -0
  337. package/AISB/image/aisb.b4.lm_reasoning.svg +16 -0
  338. package/AISB/image/aisb.b5.math_proof.svg +16 -0
  339. package/AISB/image/aisb.b6.research_process.svg +16 -0
  340. package/AISB/image/aisb.b7.multimodal_fusion.svg +16 -0
  341. package/AISB/image/aisb.b8.lifesci_drug.svg +16 -0
  342. package/AISB/image/aisb.b9.material_science.svg +16 -0
  343. package/README.md +196 -32
  344. package/bin/ds.js +924 -66
  345. package/docs/en/00_QUICK_START.md +195 -18
  346. package/docs/en/01_SETTINGS_REFERENCE.md +468 -96
  347. package/docs/en/02_START_RESEARCH_GUIDE.md +26 -5
  348. package/docs/en/03_QQ_CONNECTOR_GUIDE.md +14 -3
  349. package/docs/en/04_LINGZHU_CONNECTOR_GUIDE.md +2 -0
  350. package/docs/en/05_TUI_GUIDE.md +171 -2
  351. package/docs/en/07_MEMORY_AND_MCP.md +38 -2
  352. package/docs/en/09_DOCTOR.md +78 -7
  353. package/docs/en/10_WEIXIN_CONNECTOR_GUIDE.md +38 -1
  354. package/docs/en/11_LICENSE_AND_RISK.md +4 -0
  355. package/docs/en/12_GUIDED_WORKFLOW_TOUR.md +15 -0
  356. package/docs/en/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +9 -0
  357. package/docs/en/15_CODEX_PROVIDER_SETUP.md +624 -180
  358. package/docs/en/16_TELEGRAM_CONNECTOR_GUIDE.md +14 -0
  359. package/docs/en/17_WHATSAPP_CONNECTOR_GUIDE.md +14 -0
  360. package/docs/en/18_FEISHU_CONNECTOR_GUIDE.md +14 -0
  361. package/docs/en/21_LOCAL_MODEL_BACKENDS_GUIDE.md +386 -0
  362. package/docs/en/22_BENCHSTORE_YAML_REFERENCE.md +469 -0
  363. package/docs/en/23_BENCHSTORE_GITHUB_RELEASES_SPEC.md +316 -0
  364. package/docs/en/24_CLAUDE_CODE_PROVIDER_SETUP.md +469 -0
  365. package/docs/en/25_OPENCODE_PROVIDER_SETUP.md +653 -0
  366. package/docs/en/26_CITATION_AND_ATTRIBUTION.md +119 -0
  367. package/docs/en/27_KIMI_CODE_PROVIDER_SETUP.md +180 -0
  368. package/docs/en/28_DISCORD_CONNECTOR_GUIDE.md +61 -0
  369. package/docs/en/29_SLACK_CONNECTOR_GUIDE.md +60 -0
  370. package/docs/en/30_SETTINGS_CONTROL_CENTER_GUIDE.md +371 -0
  371. package/docs/en/{19_LOCAL_BROWSER_AUTH.md → 31_LOCAL_BROWSER_AUTH.md} +1 -1
  372. package/docs/en/32_WINDOWS_WSL2_DEPLOYMENT_GUIDE.md +273 -0
  373. package/docs/en/33_WORKSPACE_EXPLORER_QA.md +121 -0
  374. package/docs/en/91_DEVELOPMENT.md +266 -0
  375. package/docs/en/99_ACKNOWLEDGEMENTS.md +24 -19
  376. package/docs/en/README.md +48 -7
  377. package/docs/images/admin/admin-connectors-health-en.png +0 -0
  378. package/docs/images/admin/admin-controllers-en.png +0 -0
  379. package/docs/images/admin/admin-diagnostics-en.png +0 -0
  380. package/docs/images/admin/admin-errors-en.png +0 -0
  381. package/docs/images/admin/admin-issues-en.png +0 -0
  382. package/docs/images/admin/admin-logs-en.png +0 -0
  383. package/docs/images/admin/admin-quest-detail-en.png +0 -0
  384. package/docs/images/admin/admin-quests-en.png +0 -0
  385. package/docs/images/admin/admin-repairs-en.png +0 -0
  386. package/docs/images/admin/admin-runtime-en.png +0 -0
  387. package/docs/images/admin/admin-search-en.png +0 -0
  388. package/docs/images/admin/admin-stats-en.png +0 -0
  389. package/docs/images/admin/admin-summary-en.png +0 -0
  390. package/docs/images/connectors/connector-discord-en.png +0 -0
  391. package/docs/images/connectors/connector-feishu-en.png +0 -0
  392. package/docs/images/connectors/connector-lingzhu-en.png +0 -0
  393. package/docs/images/connectors/connector-qq-en.png +0 -0
  394. package/docs/images/connectors/connector-slack-en.png +0 -0
  395. package/docs/images/connectors/connector-telegram-en.png +0 -0
  396. package/docs/images/connectors/connector-weixin-en.png +0 -0
  397. package/docs/images/connectors/connector-whatsapp-en.png +0 -0
  398. package/docs/images/settings/settings-baselines-en.png +0 -0
  399. package/docs/images/settings/settings-config-en.png +0 -0
  400. package/docs/images/settings/settings-connectors-overview-en.png +0 -0
  401. package/docs/images/settings/settings-deepxiv-en.png +0 -0
  402. package/docs/images/settings/settings-mcp-servers-en.png +0 -0
  403. package/docs/images/settings/settings-plugins-en.png +0 -0
  404. package/docs/images/settings/settings-runners-en.png +0 -0
  405. package/docs/zh/00_QUICK_START.md +142 -18
  406. package/docs/zh/01_SETTINGS_REFERENCE.md +219 -98
  407. package/docs/zh/02_START_RESEARCH_GUIDE.md +26 -5
  408. package/docs/zh/05_TUI_GUIDE.md +171 -2
  409. package/docs/zh/07_MEMORY_AND_MCP.md +29 -2
  410. package/docs/zh/09_DOCTOR.md +54 -8
  411. package/docs/zh/10_WEIXIN_CONNECTOR_GUIDE.md +24 -1
  412. package/docs/zh/11_LICENSE_AND_RISK.md +4 -0
  413. package/docs/zh/12_GUIDED_WORKFLOW_TOUR.md +15 -0
  414. package/docs/zh/14_PROMPT_SKILLS_AND_MCP_GUIDE.md +9 -0
  415. package/docs/zh/15_CODEX_PROVIDER_SETUP.md +552 -181
  416. package/docs/zh/21_LOCAL_MODEL_BACKENDS_GUIDE.md +384 -0
  417. package/docs/zh/22_BENCHSTORE_YAML_REFERENCE.md +459 -0
  418. package/docs/zh/23_BENCHSTORE_GITHUB_RELEASES_SPEC.md +287 -0
  419. package/docs/zh/23_CLAUDE_RUNNER_GUIDE.md +103 -0
  420. package/docs/zh/24_CLAUDE_CODE_PROVIDER_SETUP.md +460 -0
  421. package/docs/zh/25_OPENCODE_PROVIDER_SETUP.md +660 -0
  422. package/docs/zh/26_CITATION_AND_ATTRIBUTION.md +102 -0
  423. package/docs/zh/27_KIMI_CODE_PROVIDER_SETUP.md +51 -0
  424. package/docs/zh/{19_LOCAL_BROWSER_AUTH.md → 31_LOCAL_BROWSER_AUTH.md} +1 -1
  425. package/docs/zh/32_WINDOWS_WSL2_DEPLOYMENT_GUIDE.md +264 -0
  426. package/docs/zh/33_WORKSPACE_EXPLORER_QA.md +127 -0
  427. package/docs/zh/99_ACKNOWLEDGEMENTS.md +23 -19
  428. package/docs/zh/README.md +33 -7
  429. package/install.sh +168 -20
  430. package/package.json +5 -1
  431. package/pyproject.toml +2 -1
  432. package/src/deepscientist/__init__.py +1 -1
  433. package/src/deepscientist/acp/envelope.py +13 -0
  434. package/src/deepscientist/admin/__init__.py +3 -0
  435. package/src/deepscientist/admin/charts.py +681 -0
  436. package/src/deepscientist/admin/logs.py +119 -0
  437. package/src/deepscientist/admin/repairs.py +217 -0
  438. package/src/deepscientist/admin/service.py +1310 -0
  439. package/src/deepscientist/admin/system_info.py +700 -0
  440. package/src/deepscientist/admin/tasks.py +465 -0
  441. package/src/deepscientist/admin/tool_metrics.py +600 -0
  442. package/src/deepscientist/artifact/guidance.py +8 -4
  443. package/src/deepscientist/artifact/schemas.py +115 -0
  444. package/src/deepscientist/artifact/service.py +4268 -260
  445. package/src/deepscientist/bash_exec/monitor.py +30 -3
  446. package/src/deepscientist/bash_exec/service.py +134 -1
  447. package/src/deepscientist/benchstore/__init__.py +4 -0
  448. package/src/deepscientist/benchstore/prompt_builder.py +224 -0
  449. package/src/deepscientist/benchstore/service.py +1716 -0
  450. package/src/deepscientist/bridges/connectors.py +8 -2
  451. package/src/deepscientist/channels/weixin_ilink.py +8 -1
  452. package/src/deepscientist/cli.py +92 -17
  453. package/src/deepscientist/codex_cli_compat.py +187 -74
  454. package/src/deepscientist/config/models.py +82 -11
  455. package/src/deepscientist/config/service.py +1077 -93
  456. package/src/deepscientist/connector/weixin_support.py +48 -17
  457. package/src/deepscientist/daemon/api/handlers.py +827 -235
  458. package/src/deepscientist/daemon/api/router.py +81 -1
  459. package/src/deepscientist/daemon/app.py +1512 -85
  460. package/src/deepscientist/diagnostics/__init__.py +6 -0
  461. package/src/deepscientist/diagnostics/runner_failures.py +277 -0
  462. package/src/deepscientist/doctor.py +407 -56
  463. package/src/deepscientist/evidence_packets.py +590 -0
  464. package/src/deepscientist/home.py +52 -4
  465. package/src/deepscientist/kimi_cli_compat.py +50 -0
  466. package/src/deepscientist/latex_runtime.py +2 -2
  467. package/src/deepscientist/mcp/context.py +2 -0
  468. package/src/deepscientist/mcp/schemas.py +114 -0
  469. package/src/deepscientist/mcp/server.py +1566 -126
  470. package/src/deepscientist/memory/service.py +203 -16
  471. package/src/deepscientist/process_control.py +8 -1
  472. package/src/deepscientist/prompts/builder.py +850 -88
  473. package/src/deepscientist/quest/__init__.py +2 -2
  474. package/src/deepscientist/quest/layout.py +12 -1
  475. package/src/deepscientist/quest/node_traces.py +10 -0
  476. package/src/deepscientist/quest/service.py +1852 -161
  477. package/src/deepscientist/quest/stage_views.py +1 -1
  478. package/src/deepscientist/runners/__init__.py +18 -0
  479. package/src/deepscientist/runners/base.py +89 -1
  480. package/src/deepscientist/runners/builtins.py +13 -1
  481. package/src/deepscientist/runners/claude.py +391 -0
  482. package/src/deepscientist/runners/codex.py +480 -35
  483. package/src/deepscientist/runners/codex_telemetry.py +127 -0
  484. package/src/deepscientist/runners/kimi.py +334 -0
  485. package/src/deepscientist/runners/metadata.py +68 -0
  486. package/src/deepscientist/runners/opencode.py +414 -0
  487. package/src/deepscientist/runners/runtime_overrides.py +100 -0
  488. package/src/deepscientist/runners/simple_cli.py +538 -0
  489. package/src/deepscientist/runtime_storage.py +303 -0
  490. package/src/deepscientist/shared.py +80 -16
  491. package/src/deepscientist/skills/installer.py +37 -0
  492. package/src/deepscientist/skills/registry.py +2 -0
  493. package/src/deepscientist/tinytex.py +2 -2
  494. package/src/deepscientist/tui.py +10 -3
  495. package/src/prompts/benchstore/system.md +77 -0
  496. package/src/prompts/connectors/qq.md +33 -2
  497. package/src/prompts/connectors/weixin.md +208 -23
  498. package/src/prompts/contracts/admin_ops.md +74 -0
  499. package/src/prompts/contracts/admin_ops_knowledge.md +138 -0
  500. package/src/prompts/contracts/shared_interaction.md +5 -10
  501. package/src/prompts/start_setup/system.md +422 -0
  502. package/src/prompts/system.md +411 -304
  503. package/src/prompts/system_copilot.md +89 -0
  504. package/src/skills/analysis-campaign/SKILL.md +239 -578
  505. package/src/skills/analysis-campaign/references/artifact-flow-examples.md +102 -0
  506. package/src/skills/analysis-campaign/references/boundary-cases.md +98 -0
  507. package/src/skills/analysis-campaign/references/campaign-checklist-template.md +39 -24
  508. package/src/skills/analysis-campaign/references/campaign-design.md +26 -10
  509. package/src/skills/analysis-campaign/references/campaign-plan-template.md +53 -54
  510. package/src/skills/analysis-campaign/references/operational-guidance.md +97 -0
  511. package/src/skills/analysis-campaign/references/writing-facing-slice-examples.md +10 -20
  512. package/src/skills/baseline/SKILL.md +183 -461
  513. package/src/skills/baseline/references/artifact-flow-examples.md +106 -0
  514. package/src/skills/baseline/references/artifact-payload-examples.md +1 -1
  515. package/src/skills/baseline/references/baseline-checklist-template.md +27 -35
  516. package/src/skills/baseline/references/baseline-plan-template.md +37 -76
  517. package/src/skills/baseline/references/boundary-cases.md +86 -0
  518. package/src/skills/baseline/references/codebase-audit-checklist.md +2 -6
  519. package/src/skills/baseline/references/comparability-contract.md +7 -12
  520. package/src/skills/baseline/references/operational-guidance.md +56 -0
  521. package/src/skills/baseline/references/route-selection.md +5 -25
  522. package/src/skills/decision/SKILL.md +113 -306
  523. package/src/skills/decision/references/checkpoint-memory-template.md +47 -0
  524. package/src/skills/decision/references/operational-guidance.md +94 -0
  525. package/src/skills/decision/references/research-route-criteria.md +7 -8
  526. package/src/skills/decision/references/strategic-decision-template.md +13 -26
  527. package/src/skills/experiment/SKILL.md +132 -670
  528. package/src/skills/experiment/references/execution-playbook.md +374 -0
  529. package/src/skills/experiment/references/main-experiment-checklist-template.md +26 -2
  530. package/src/skills/experiment/references/main-experiment-plan-template.md +28 -17
  531. package/src/skills/experiment/references/operational-guidance.md +108 -0
  532. package/src/skills/finalize/SKILL.md +62 -0
  533. package/src/skills/finalize/references/checkpoint-memory-template.md +49 -0
  534. package/src/skills/finalize/references/resume-packet-template.md +7 -0
  535. package/src/skills/idea/SKILL.md +228 -15
  536. package/src/skills/idea/references/controlled-brainstorming-playbook.md +78 -0
  537. package/src/skills/idea/references/current-board-packet-template.md +61 -0
  538. package/src/skills/idea/references/high-value-idea-sourcing.md +119 -0
  539. package/src/skills/idea/references/idea-generation-playbook.md +21 -0
  540. package/src/skills/idea/references/idea-thinking-flow.md +6 -0
  541. package/src/skills/idea/references/literature-survey-template.md +3 -0
  542. package/src/skills/idea/references/objective-contract-template.md +54 -0
  543. package/src/skills/idea/references/outline-seeding-example.md +56 -0
  544. package/src/skills/idea/references/pre-idea-draft-template.md +105 -0
  545. package/src/skills/idea/references/related-work-playbook.md +75 -2
  546. package/src/skills/idea/references/research-history-playbook.md +114 -0
  547. package/src/skills/idea/references/selection-gate.md +58 -6
  548. package/src/skills/intake-audit/SKILL.md +43 -2
  549. package/src/skills/intake-audit/references/state-audit-template.md +10 -0
  550. package/src/skills/nature-data/SKILL.md +128 -0
  551. package/src/skills/nature-data/UPSTREAM_LICENSE.txt +21 -0
  552. package/src/skills/nature-data/agents/openai.yaml +4 -0
  553. package/src/skills/nature-data/references/chinese-author-alignment.md +84 -0
  554. package/src/skills/nature-data/references/fair-metadata-checklist.md +105 -0
  555. package/src/skills/nature-data/references/policy-principles.md +103 -0
  556. package/src/skills/nature-data/references/repository-and-identifiers.md +96 -0
  557. package/src/skills/nature-data/references/source-basis.md +54 -0
  558. package/src/skills/nature-data/references/statement-patterns.md +153 -0
  559. package/src/skills/nature-figure/SKILL.md +197 -0
  560. package/src/skills/nature-figure/UPSTREAM_LICENSE.txt +21 -0
  561. package/src/skills/nature-figure/agents/openai.yaml +4 -0
  562. package/src/skills/nature-figure/evals/evals.json +37 -0
  563. package/src/skills/nature-figure/references/api.md +428 -0
  564. package/src/skills/nature-figure/references/backend-selection.md +100 -0
  565. package/src/skills/nature-figure/references/chart-types.md +281 -0
  566. package/src/skills/nature-figure/references/common-patterns.md +349 -0
  567. package/src/skills/nature-figure/references/design-theory.md +436 -0
  568. package/src/skills/nature-figure/references/figure-contract.md +93 -0
  569. package/src/skills/nature-figure/references/nature-2026-observations.md +112 -0
  570. package/src/skills/nature-figure/references/qa-contract.md +119 -0
  571. package/src/skills/nature-figure/references/r-template-index.md +66 -0
  572. package/src/skills/nature-figure/references/r-workflow.md +161 -0
  573. package/src/skills/nature-figure/references/tutorials.md +250 -0
  574. package/src/skills/nature-paper2ppt/SKILL.md +507 -0
  575. package/src/skills/nature-paper2ppt/UPSTREAM_LICENSE.txt +21 -0
  576. package/src/skills/nature-paper2ppt/agents/openai.yaml +4 -0
  577. package/src/skills/nature-polishing/SKILL.md +385 -0
  578. package/src/skills/nature-polishing/UPSTREAM_LICENSE.txt +21 -0
  579. package/src/skills/nature-polishing/agents/openai.yaml +4 -0
  580. package/src/skills/nature-polishing/references/phrasebank-playbook.md +162 -0
  581. package/src/skills/nature-polishing/references/section-moves.md +240 -0
  582. package/src/skills/nature-polishing/references/style-guardrails.md +94 -0
  583. package/src/skills/nature-polishing/references/writing-strategy.md +148 -0
  584. package/src/skills/optimize/SKILL.md +177 -1568
  585. package/src/skills/optimize/references/brief-shaping-playbook.md +95 -0
  586. package/src/skills/optimize/references/candidate-board-template.md +13 -0
  587. package/src/skills/optimize/references/candidate-ranking-template.md +51 -0
  588. package/src/skills/optimize/references/codegen-route-playbook.md +50 -0
  589. package/src/skills/optimize/references/debug-response-template.md +29 -0
  590. package/src/skills/optimize/references/frontier-review-template.md +32 -0
  591. package/src/skills/optimize/references/fusion-playbook.md +36 -0
  592. package/src/skills/optimize/references/method-brief-template.md +73 -0
  593. package/src/skills/optimize/references/operational-guidance.md +621 -0
  594. package/src/skills/optimize/references/optimization-memory-template.md +30 -0
  595. package/src/skills/optimize/references/optimize-checklist-template.md +18 -0
  596. package/src/skills/optimize/references/plateau-response-playbook.md +28 -0
  597. package/src/skills/optimize/references/prompt-patterns.md +49 -0
  598. package/src/skills/paper-outline/SKILL.md +227 -0
  599. package/src/skills/paper-outline/references/outline-patterns.md +87 -0
  600. package/src/skills/paper-plot/SKILL.md +79 -0
  601. package/src/skills/paper-plot/agents/openai.yaml +4 -0
  602. package/src/skills/paper-plot/references/bar_grouped_hatch.md +96 -0
  603. package/src/skills/paper-plot/references/bar_paired_delta.md +72 -0
  604. package/src/skills/paper-plot/references/line_confidence_band.md +75 -0
  605. package/src/skills/paper-plot/references/line_loss_with_inset.md +65 -0
  606. package/src/skills/paper-plot/references/line_training_curve.md +44 -0
  607. package/src/skills/paper-plot/references/radar_dual_series.md +59 -0
  608. package/src/skills/paper-plot/references/scatter_broken_axis.md +59 -0
  609. package/src/skills/paper-plot/references/scatter_tsne_cluster.md +72 -0
  610. package/src/skills/paper-plot/scripts/bar_memevolve.py +109 -0
  611. package/src/skills/paper-plot/scripts/bar_spice.py +166 -0
  612. package/src/skills/paper-plot/scripts/line_aime.py +94 -0
  613. package/src/skills/paper-plot/scripts/line_loss_inset.py +157 -0
  614. package/src/skills/paper-plot/scripts/line_selfdistill.py +168 -0
  615. package/src/skills/paper-plot/scripts/radar_dora.py +151 -0
  616. package/src/skills/paper-plot/scripts/scatter_break.py +169 -0
  617. package/src/skills/paper-plot/scripts/scatter_tsne.py +133 -0
  618. package/src/skills/rebuttal/SKILL.md +9 -0
  619. package/src/skills/references/tool-usage-by-stage.md +438 -0
  620. package/src/skills/review/SKILL.md +105 -7
  621. package/src/skills/science/PROVENANCE.md +44 -0
  622. package/src/skills/science/SKILL.md +137 -0
  623. package/src/skills/science/references/artifact-science-tool.md +110 -0
  624. package/src/skills/science/references/claim-type-discipline.md +56 -0
  625. package/src/skills/science/references/domain-index.md +422 -0
  626. package/src/skills/science/references/hpc-via-bash-exec.md +42 -0
  627. package/src/skills/science/references/package-check-playbook.md +64 -0
  628. package/src/skills/science/references/package-index.min.json +3616 -0
  629. package/src/skills/science/references/packages/abinit.md +80 -0
  630. package/src/skills/science/references/packages/acts.md +73 -0
  631. package/src/skills/science/references/packages/aiida-core.md +80 -0
  632. package/src/skills/science/references/packages/alamode.md +80 -0
  633. package/src/skills/science/references/packages/amuse.md +88 -0
  634. package/src/skills/science/references/packages/anndata.md +88 -0
  635. package/src/skills/science/references/packages/arbor.md +80 -0
  636. package/src/skills/science/references/packages/arc.md +73 -0
  637. package/src/skills/science/references/packages/astropy.md +88 -0
  638. package/src/skills/science/references/packages/astroquery.md +88 -0
  639. package/src/skills/science/references/packages/atomate2.md +80 -0
  640. package/src/skills/science/references/packages/atomsmltr.md +73 -0
  641. package/src/skills/science/references/packages/awkward.md +73 -0
  642. package/src/skills/science/references/packages/batman.md +88 -0
  643. package/src/skills/science/references/packages/biopython.md +88 -0
  644. package/src/skills/science/references/packages/bloqade.md +73 -0
  645. package/src/skills/science/references/packages/brian2.md +73 -0
  646. package/src/skills/science/references/packages/bullet3.md +73 -0
  647. package/src/skills/science/references/packages/calculix.md +80 -0
  648. package/src/skills/science/references/packages/cantera.md +73 -0
  649. package/src/skills/science/references/packages/cavity-md-ipi.md +80 -0
  650. package/src/skills/science/references/packages/ccdproc.md +88 -0
  651. package/src/skills/science/references/packages/celerite2.md +88 -0
  652. package/src/skills/science/references/packages/cellrank.md +73 -0
  653. package/src/skills/science/references/packages/cesm.md +80 -0
  654. package/src/skills/science/references/packages/chemicals.md +73 -0
  655. package/src/skills/science/references/packages/chempy.md +73 -0
  656. package/src/skills/science/references/packages/cirq.md +73 -0
  657. package/src/skills/science/references/packages/coffea.md +73 -0
  658. package/src/skills/science/references/packages/cp2k.md +88 -0
  659. package/src/skills/science/references/packages/custodian.md +80 -0
  660. package/src/skills/science/references/packages/dart.md +73 -0
  661. package/src/skills/science/references/packages/datamol.md +88 -0
  662. package/src/skills/science/references/packages/dd4hep.md +73 -0
  663. package/src/skills/science/references/packages/dealii.md +80 -0
  664. package/src/skills/science/references/packages/deepchem.md +88 -0
  665. package/src/skills/science/references/packages/delphes.md +73 -0
  666. package/src/skills/science/references/packages/devito.md +80 -0
  667. package/src/skills/science/references/packages/dftb.md +88 -0
  668. package/src/skills/science/references/packages/dftd4.md +88 -0
  669. package/src/skills/science/references/packages/dftk-jl.md +80 -0
  670. package/src/skills/science/references/packages/dolfinx.md +80 -0
  671. package/src/skills/science/references/packages/drake.md +73 -0
  672. package/src/skills/science/references/packages/dumux.md +73 -0
  673. package/src/skills/science/references/packages/elk.md +80 -0
  674. package/src/skills/science/references/packages/elmerfem.md +80 -0
  675. package/src/skills/science/references/packages/enzo-e.md +88 -0
  676. package/src/skills/science/references/packages/espresso.md +80 -0
  677. package/src/skills/science/references/packages/exoplanet.md +88 -0
  678. package/src/skills/science/references/packages/fairroot.md +73 -0
  679. package/src/skills/science/references/packages/fbpic.md +80 -0
  680. package/src/skills/science/references/packages/fdtdbath-meep.md +80 -0
  681. package/src/skills/science/references/packages/geant4.md +73 -0
  682. package/src/skills/science/references/packages/geosx.md +80 -0
  683. package/src/skills/science/references/packages/gprmax.md +80 -0
  684. package/src/skills/science/references/packages/gromacs.md +80 -0
  685. package/src/skills/science/references/packages/gwaslab.md +73 -0
  686. package/src/skills/science/references/packages/gz-sim.md +73 -0
  687. package/src/skills/science/references/packages/hail.md +88 -0
  688. package/src/skills/science/references/packages/hiphive.md +80 -0
  689. package/src/skills/science/references/packages/hoomd-blue.md +80 -0
  690. package/src/skills/science/references/packages/itensor.md +73 -0
  691. package/src/skills/science/references/packages/itensors-jl.md +73 -0
  692. package/src/skills/science/references/packages/jdftx.md +73 -0
  693. package/src/skills/science/references/packages/jobflow.md +80 -0
  694. package/src/skills/science/references/packages/kadanoffbaym-jl.md +73 -0
  695. package/src/skills/science/references/packages/kite.md +80 -0
  696. package/src/skills/science/references/packages/kratos.md +80 -0
  697. package/src/skills/science/references/packages/kwant.md +73 -0
  698. package/src/skills/science/references/packages/lammps.md +80 -0
  699. package/src/skills/science/references/packages/lightkurve.md +88 -0
  700. package/src/skills/science/references/packages/limix.md +73 -0
  701. package/src/skills/science/references/packages/maxwelllink.md +80 -0
  702. package/src/skills/science/references/packages/mcdc.md +73 -0
  703. package/src/skills/science/references/packages/meep.md +80 -0
  704. package/src/skills/science/references/packages/mfem.md +80 -0
  705. package/src/skills/science/references/packages/mitgcm.md +73 -0
  706. package/src/skills/science/references/packages/modflow6.md +73 -0
  707. package/src/skills/science/references/packages/molecool.md +73 -0
  708. package/src/skills/science/references/packages/mom6.md +73 -0
  709. package/src/skills/science/references/packages/moose.md +80 -0
  710. package/src/skills/science/references/packages/mpas-model.md +73 -0
  711. package/src/skills/science/references/packages/mujoco.md +73 -0
  712. package/src/skills/science/references/packages/mumax3.md +73 -0
  713. package/src/skills/science/references/packages/nekrs.md +80 -0
  714. package/src/skills/science/references/packages/nessi.md +73 -0
  715. package/src/skills/science/references/packages/nest-simulator.md +73 -0
  716. package/src/skills/science/references/packages/netket.md +73 -0
  717. package/src/skills/science/references/packages/neuron.md +73 -0
  718. package/src/skills/science/references/packages/nextflow.md +88 -0
  719. package/src/skills/science/references/packages/nwchem.md +88 -0
  720. package/src/skills/science/references/packages/openbabel.md +88 -0
  721. package/src/skills/science/references/packages/openems.md +80 -0
  722. package/src/skills/science/references/packages/openff-toolkit.md +88 -0
  723. package/src/skills/science/references/packages/openfoam-dev.md +80 -0
  724. package/src/skills/science/references/packages/openmc.md +73 -0
  725. package/src/skills/science/references/packages/openmm.md +80 -0
  726. package/src/skills/science/references/packages/openmoc.md +73 -0
  727. package/src/skills/science/references/packages/openmx.md +80 -0
  728. package/src/skills/science/references/packages/opensees.md +80 -0
  729. package/src/skills/science/references/packages/opensn.md +80 -0
  730. package/src/skills/science/references/packages/opm-simulators.md +73 -0
  731. package/src/skills/science/references/packages/oqupy.md +73 -0
  732. package/src/skills/science/references/packages/packmol.md +80 -0
  733. package/src/skills/science/references/packages/palabos.md +80 -0
  734. package/src/skills/science/references/packages/parflow.md +80 -0
  735. package/src/skills/science/references/packages/pennylane.md +88 -0
  736. package/src/skills/science/references/packages/perceval.md +73 -0
  737. package/src/skills/science/references/packages/phono3py.md +73 -0
  738. package/src/skills/science/references/packages/phonopy.md +73 -0
  739. package/src/skills/science/references/packages/photutils.md +88 -0
  740. package/src/skills/science/references/packages/picongpu.md +80 -0
  741. package/src/skills/science/references/packages/plink-ng.md +88 -0
  742. package/src/skills/science/references/packages/precice.md +73 -0
  743. package/src/skills/science/references/packages/psc.md +80 -0
  744. package/src/skills/science/references/packages/psi4.md +88 -0
  745. package/src/skills/science/references/packages/pybinding.md +73 -0
  746. package/src/skills/science/references/packages/pyfr.md +80 -0
  747. package/src/skills/science/references/packages/pyhf.md +73 -0
  748. package/src/skills/science/references/packages/pyiron_base.md +80 -0
  749. package/src/skills/science/references/packages/pylcp.md +73 -0
  750. package/src/skills/science/references/packages/pylith.md +80 -0
  751. package/src/skills/science/references/packages/pynbody.md +88 -0
  752. package/src/skills/science/references/packages/pysam.md +88 -0
  753. package/src/skills/science/references/packages/pyscf.md +88 -0
  754. package/src/skills/science/references/packages/q-e.md +73 -0
  755. package/src/skills/science/references/packages/qibo.md +73 -0
  756. package/src/skills/science/references/packages/qiskit.md +73 -0
  757. package/src/skills/science/references/packages/quantica-jl.md +73 -0
  758. package/src/skills/science/references/packages/quantumoptics-jl.md +73 -0
  759. package/src/skills/science/references/packages/quimb.md +73 -0
  760. package/src/skills/science/references/packages/qulacs.md +73 -0
  761. package/src/skills/science/references/packages/qutip.md +73 -0
  762. package/src/skills/science/references/packages/rdkit.md +88 -0
  763. package/src/skills/science/references/packages/rmg-py.md +73 -0
  764. package/src/skills/science/references/packages/root.md +73 -0
  765. package/src/skills/science/references/packages/scanpy.md +88 -0
  766. package/src/skills/science/references/packages/scikit-allel.md +88 -0
  767. package/src/skills/science/references/packages/scikit-bio.md +88 -0
  768. package/src/skills/science/references/packages/scqubits.md +73 -0
  769. package/src/skills/science/references/packages/scuff-em.md +80 -0
  770. package/src/skills/science/references/packages/scvi-tools.md +73 -0
  771. package/src/skills/science/references/packages/seissol.md +73 -0
  772. package/src/skills/science/references/packages/sfepy.md +80 -0
  773. package/src/skills/science/references/packages/sisl.md +73 -0
  774. package/src/skills/science/references/packages/smilei.md +80 -0
  775. package/src/skills/science/references/packages/snakemake.md +88 -0
  776. package/src/skills/science/references/packages/specfem3d-globe.md +80 -0
  777. package/src/skills/science/references/packages/specutils.md +88 -0
  778. package/src/skills/science/references/packages/spglib.md +80 -0
  779. package/src/skills/science/references/packages/squidpy.md +88 -0
  780. package/src/skills/science/references/packages/starry.md +88 -0
  781. package/src/skills/science/references/packages/strawberryfields.md +73 -0
  782. package/src/skills/science/references/packages/su2.md +80 -0
  783. package/src/skills/science/references/packages/sunny-jl.md +73 -0
  784. package/src/skills/science/references/packages/sw4.md +73 -0
  785. package/src/skills/science/references/packages/swift.md +88 -0
  786. package/src/skills/science/references/packages/tdnegf.md +73 -0
  787. package/src/skills/science/references/packages/tenpy.md +73 -0
  788. package/src/skills/science/references/packages/thermo.md +73 -0
  789. package/src/skills/science/references/packages/tkwant.md +73 -0
  790. package/src/skills/science/references/packages/tvb-root.md +73 -0
  791. package/src/skills/science/references/packages/uproot5.md +73 -0
  792. package/src/skills/science/references/packages/vampire.md +80 -0
  793. package/src/skills/science/references/packages/wannier_tools.md +73 -0
  794. package/src/skills/science/references/packages/warpx.md +80 -0
  795. package/src/skills/science/references/packages/wrf.md +73 -0
  796. package/src/skills/science/references/packages/xtb.md +88 -0
  797. package/src/skills/science/references/packages/yt.md +73 -0
  798. package/src/skills/science/references/science-task-brief-template.md +71 -0
  799. package/src/skills/scout/SKILL.md +83 -425
  800. package/src/skills/scout/references/literature-scout-template.md +5 -24
  801. package/src/skills/scout/references/operational-guidance.md +191 -0
  802. package/src/skills/scout/references/paper-triage-playbook.md +11 -35
  803. package/src/skills/write/SKILL.md +744 -1246
  804. package/src/skills/write/references/experiments_analysis_patterns.md +129 -0
  805. package/src/skills/write/references/oral_package_patterns.md +252 -0
  806. package/src/skills/write/references/oral_writing_principles.md +291 -0
  807. package/src/skills/write/references/section_rewrite_checklist.md +234 -0
  808. package/src/tui/dist/app/AppContainer.js +1314 -27
  809. package/src/tui/dist/components/Composer.js +26 -1
  810. package/src/tui/dist/components/ConfigScreen.js +2 -1
  811. package/src/tui/dist/components/InputPrompt.js +25 -9
  812. package/src/tui/dist/components/MainContent.js +18 -3
  813. package/src/tui/dist/components/QuestScreen.js +3 -2
  814. package/src/tui/dist/components/UtilityScreen.js +37 -0
  815. package/src/tui/dist/hooks/useSafeInput.js +10 -0
  816. package/src/tui/dist/index.js +13 -1
  817. package/src/tui/dist/layouts/DefaultAppLayout.js +11 -8
  818. package/src/tui/dist/lib/api.js +89 -1
  819. package/src/tui/package.json +1 -1
  820. package/src/ui/dist/assets/{AnalysisPlugin-DnSm0GZn.js → AnalysisPlugin-CA94NGmI.js} +1 -1
  821. package/src/ui/dist/assets/CliPlugin-DHBzphZU.js +79 -0
  822. package/src/ui/dist/assets/CodeEditorPlugin-BOFwD2rn.js +2 -0
  823. package/src/ui/dist/assets/{CodeViewerPlugin-itb0tltR.js → CodeViewerPlugin-CqDpgjik.js} +4 -4
  824. package/src/ui/dist/assets/{DocViewerPlugin-DqKkiCI6.js → DocViewerPlugin-UDBgt8-4.js} +3 -3
  825. package/src/ui/dist/assets/GitCommitViewerPlugin-BmHtZ0bZ.js +6 -0
  826. package/src/ui/dist/assets/{GitDiffViewerPlugin-DxL2ezFG.js → GitDiffViewerPlugin-CAxjNorQ.js} +2 -2
  827. package/src/ui/dist/assets/{GitSnapshotViewer-B_RQm1YZ.js → GitSnapshotViewer-CweA6VON.js} +2 -2
  828. package/src/ui/dist/assets/{ImageViewerPlugin-tHqlXY3n.js → ImageViewerPlugin-C8wHGvGN.js} +5 -5
  829. package/src/ui/dist/assets/LabPlugin-COyyLUol.js +32 -0
  830. package/src/ui/dist/assets/{LatexPlugin-B495DTXC.js → LatexPlugin-BQjAaA5J.js} +4 -4
  831. package/src/ui/dist/assets/{MarkdownViewerPlugin-DG28-61B.js → MarkdownViewerPlugin-Dy1NE2dI.js} +3 -3
  832. package/src/ui/dist/assets/{MarketplacePlugin-BiOGT-Kj.js → MarketplacePlugin-DMIZtEJ2.js} +2 -2
  833. package/src/ui/dist/assets/NotebookEditor-CFHMq_Qt.js +91 -0
  834. package/src/ui/dist/assets/{NotebookEditor-CVsj8h_T.js → NotebookEditor-WFyd8Ybt.js} +23 -23
  835. package/src/ui/dist/assets/{PdfLoader-CASDQmxJ.js → PdfLoader-CLE5u5TS.js} +3 -3
  836. package/src/ui/dist/assets/{PdfMarkdownPlugin-BFhwoKsY.js → PdfMarkdownPlugin-_iNK_H83.js} +1 -1
  837. package/src/ui/dist/assets/PdfViewerPlugin-DgWsbInT.js +22 -0
  838. package/src/ui/dist/assets/SearchPlugin-DrZmn5iw.js +11 -0
  839. package/src/ui/dist/assets/{TextViewerPlugin-CB4DYfWO.js → TextViewerPlugin-D1-T3aC7.js} +4 -4
  840. package/src/ui/dist/assets/branding/runner-claude.svg +107 -0
  841. package/src/ui/dist/assets/branding/runner-codex.svg +10 -0
  842. package/src/ui/dist/assets/branding/runner-kimi.svg +14 -0
  843. package/src/ui/dist/assets/branding/runner-opencode.svg +7 -0
  844. package/src/ui/dist/assets/cli-store-CoZ-x5Ip.js +1 -0
  845. package/src/ui/dist/assets/{code-DLC6G24T.js → code-DbsmSd3Y.js} +1 -1
  846. package/src/ui/dist/assets/file-diff-panel-DsvyRz47.js +1 -0
  847. package/src/ui/dist/assets/{wrap-text-CwMn-iqb.js → file-jump-queue-DeQBikaw.js} +3 -3
  848. package/src/ui/dist/assets/{file-socket-Cu4Qln7Y.js → file-socket-DA5XIx88.js} +1 -1
  849. package/src/ui/dist/assets/fonts/ds-fonts.css +50 -4
  850. package/src/ui/dist/assets/images/deepxiv/register-guide.png +0 -0
  851. package/src/ui/dist/assets/index-39vY9LmZ.js +1 -0
  852. package/src/ui/dist/assets/{index-wQ7RIIRd.js → index-BsO46tJA.js} +1 -1
  853. package/src/ui/dist/assets/index-CHzJ2xtB.js +3530 -0
  854. package/src/ui/dist/assets/index-DH-zxoZ3.css +33 -0
  855. package/src/ui/dist/assets/{plugin-notebook-HbW2K-1c.js → plugin-notebook-JRhysCqj.js} +2 -2
  856. package/src/ui/dist/assets/{project-sync-CsX08Qno.js → project-sync-DPmWKmKD.js} +1 -1
  857. package/src/ui/dist/assets/{zoom-out-R-GWEhzS.js → zoom-out-DAukFWen.js} +3 -3
  858. package/src/ui/dist/index.html +3 -3
  859. package/src/skills/analysis-campaign/references/artifact-orchestration.md +0 -58
  860. package/src/skills/baseline/references/memory-playbook.md +0 -40
  861. package/src/skills/baseline/references/publishable-baseline-package.md +0 -30
  862. package/src/skills/write/references/outline-evidence-contract-example.md +0 -107
  863. package/src/skills/write/references/paper-experiment-matrix-template.md +0 -131
  864. package/src/skills/write/references/paper-section-playbook.md +0 -64
  865. package/src/skills/write/references/reviewer-first-writing.md +0 -64
  866. package/src/skills/write/references/revision-checklist.md +0 -70
  867. package/src/skills/write/references/section-contracts.md +0 -82
  868. package/src/skills/write/references/sentence-level-proofing.md +0 -49
  869. package/src/ui/dist/assets/AiManusChatView-COFACy7V.js +0 -204
  870. package/src/ui/dist/assets/CliPlugin-CvwCmDQ5.js +0 -109
  871. package/src/ui/dist/assets/CodeEditorPlugin-cOqSa0xq.js +0 -2
  872. package/src/ui/dist/assets/GitCommitViewerPlugin-DVgNHBCS.js +0 -1
  873. package/src/ui/dist/assets/LabCopilotPanel-ClMbq5Yu.js +0 -14
  874. package/src/ui/dist/assets/LabPlugin-L_SuE8ow.js +0 -22
  875. package/src/ui/dist/assets/NotebookEditor-C-4Kt1p9.js +0 -81
  876. package/src/ui/dist/assets/PdfViewerPlugin-DcOzU9vd.js +0 -17
  877. package/src/ui/dist/assets/SearchPlugin-CHj7M58O.js +0 -16
  878. package/src/ui/dist/assets/VNCViewer-CjlbyCB3.js +0 -11
  879. package/src/ui/dist/assets/bot-CFkZY-JP.js +0 -6
  880. package/src/ui/dist/assets/chevron-up-Dq5ofbht.js +0 -6
  881. package/src/ui/dist/assets/file-content-Dv4LoZec.js +0 -1
  882. package/src/ui/dist/assets/file-diff-panel-Denq-lC3.js +0 -1
  883. package/src/ui/dist/assets/file-jump-queue-DA-SdG__.js +0 -1
  884. package/src/ui/dist/assets/git-commit-horizontal-BUh6G52n.js +0 -6
  885. package/src/ui/dist/assets/image-B9HUUddG.js +0 -6
  886. package/src/ui/dist/assets/index-B2B1sg-M.js +0 -1
  887. package/src/ui/dist/assets/index-Cgla8biy.css +0 -33
  888. package/src/ui/dist/assets/index-DRyx7vAc.js +0 -1
  889. package/src/ui/dist/assets/index-Gbl53BNp.js +0 -2496
  890. package/src/ui/dist/assets/pdf-effect-queue-ZtnHFCAi.js +0 -6
  891. package/src/ui/dist/assets/popover-DL6h35vr.js +0 -1
  892. package/src/ui/dist/assets/select-DvmXt1yY.js +0 -11
  893. package/src/ui/dist/assets/sigma-7jpXazui.js +0 -6
  894. package/src/ui/dist/assets/trash-xA7kFt8i.js +0 -11
  895. package/src/ui/dist/assets/useCliAccess-DsMwDjOp.js +0 -1
  896. package/src/ui/dist/assets/useFileDiffOverlay-FuhcnKiw.js +0 -1
@@ -5,10 +5,10 @@ from collections import deque
5
5
  from contextlib import contextmanager
6
6
  from datetime import UTC, datetime, timedelta
7
7
  import hashlib
8
- import subprocess
9
8
  import json
10
9
  import mimetypes
11
10
  import re
11
+ import shutil
12
12
  import threading
13
13
  import time
14
14
  from pathlib import Path, PurePosixPath
@@ -27,11 +27,12 @@ from ..file_lock import advisory_file_lock
27
27
  from ..gitops import current_branch, export_git_graph, head_commit, init_repo, list_branch_canvas, list_commit_canvas
28
28
  from ..home import repo_root
29
29
  from ..registries import BaselineRegistry
30
- from ..shared import append_jsonl, ensure_dir, generate_id, iter_jsonl, read_json, read_jsonl, read_jsonl_tail, read_text, read_yaml, resolve_within, run_command, sha256_text, slugify, utc_now, write_json, write_text, write_yaml
30
+ from ..shared import append_jsonl, ensure_dir, generate_id, iter_jsonl, read_json, read_jsonl, read_jsonl_tail, read_text, read_yaml, resolve_within, run_command, run_command_bytes, sha256_text, slugify, utc_now, write_json, write_text, write_yaml
31
31
  from ..skills import SkillInstaller
32
32
  from ..web_search import extract_web_search_payload
33
33
  from .layout import (
34
34
  QUEST_DIRECTORIES,
35
+ default_active_anchor,
35
36
  gitignore,
36
37
  initial_brief,
37
38
  initial_plan,
@@ -44,21 +45,34 @@ from .stage_views import QuestStageViewBuilder
44
45
 
45
46
  _UNSET = object()
46
47
  _NUMERIC_QUEST_ID_PATTERN = re.compile(r"^\d{1,10}$")
48
+ _SYSTEM_NUMERIC_QUEST_ID_PATTERN = re.compile(r"^(?P<prefix>[SB])-(?P<number>\d{1,10})$", re.IGNORECASE)
47
49
  _MAX_NUMERIC_QUEST_ID_VALUE = 9_999_999_999
48
50
  _NUMERIC_QUEST_ID_PAD_WIDTH = 3
49
51
  _CRASH_AUTO_RESUME_WINDOW = timedelta(hours=24)
50
52
  _JSONL_CACHE_MAX_BYTES = 4 * 1024 * 1024
51
53
  _CODEX_HISTORY_TAIL_LIMIT = 400
52
54
  _JSONL_STREAM_CHUNK_BYTES = 64 * 1024
55
+ _JSONL_LINE_COUNT_CACHE_SUFFIX = ".linecount.json"
53
56
  _EVENTS_OVERSIZED_LINE_BYTES = 8 * 1024 * 1024
54
57
  _OVERSIZED_EVENT_PREFIX_BYTES = 4096
55
58
  _PROJECTION_SCHEMA_VERSION = 1
56
59
  _PROJECTION_BUILD_TOTAL_STEPS = 3
57
60
  _PROJECTION_REFRESH_THROTTLE_SECONDS = 1.0
61
+ _SHARED_MEMORY_DOCUMENT_PREFIX = "sharedmemory::"
58
62
  _EVENT_TYPE_BYTES_RE = re.compile(rb'"(?:type|event_type)"\s*:\s*"([^"]+)"')
59
63
  _EVENT_TOOL_NAME_BYTES_RE = re.compile(rb'"tool_name"\s*:\s*"([^"]+)"')
60
64
  _EVENT_RUN_ID_BYTES_RE = re.compile(rb'"run_id"\s*:\s*"([^"]+)"')
61
65
  CONTINUATION_POLICIES = {"auto", "when_external_progress", "wait_for_user_or_resume", "none"}
66
+ AUTONOMOUS_BLOCKING_WAIT_REASONS = {
67
+ "completion_approval",
68
+ "credential_required",
69
+ "privacy_or_data_export_boundary",
70
+ "large_cost_or_external_paid_api",
71
+ "user_gated_decision_request",
72
+ }
73
+ _CHAT_ATTACHMENT_TEXT_EXTENSIONS = {".txt", ".md", ".markdown", ".mdx", ".json", ".csv", ".log", ".yaml", ".yml"}
74
+ _CHAT_ATTACHMENT_TEXT_MIME_PREFIXES = ("text/",)
75
+ _CHAT_ATTACHMENT_TEXT_MIME_TYPES = {"application/json", "application/x-yaml", "text/csv"}
62
76
 
63
77
 
64
78
  def _oversized_event_placeholder(*, prefix: bytes, line_bytes: int) -> dict[str, Any]:
@@ -101,6 +115,7 @@ def _iter_jsonl_records_safely(
101
115
  prefix = bytearray()
102
116
  current_bytes = 0
103
117
  oversized = False
118
+ cursor = 0
104
119
  while True:
105
120
  chunk = handle.read(_JSONL_STREAM_CHUNK_BYTES)
106
121
  if not chunk:
@@ -114,7 +129,8 @@ def _iter_jsonl_records_safely(
114
129
  if oversized:
115
130
  current_bytes += len(segment)
116
131
  if has_newline:
117
- yield _oversized_event_placeholder(prefix=bytes(prefix), line_bytes=current_bytes)
132
+ cursor += 1
133
+ yield cursor, _oversized_event_placeholder(prefix=bytes(prefix), line_bytes=current_bytes)
118
134
  prefix = bytearray()
119
135
  current_bytes = 0
120
136
  oversized = False
@@ -133,7 +149,8 @@ def _iter_jsonl_records_safely(
133
149
  current_bytes = next_bytes
134
150
  oversized = True
135
151
  if has_newline:
136
- yield _oversized_event_placeholder(prefix=bytes(prefix), line_bytes=current_bytes)
152
+ cursor += 1
153
+ yield cursor, _oversized_event_placeholder(prefix=bytes(prefix), line_bytes=current_bytes)
137
154
  prefix = bytearray()
138
155
  current_bytes = 0
139
156
  oversized = False
@@ -148,28 +165,27 @@ def _iter_jsonl_records_safely(
148
165
  buffer.clear()
149
166
  line_bytes = current_bytes
150
167
  current_bytes = 0
151
- if raw:
152
- try:
153
- payload = json.loads(raw)
154
- except json.JSONDecodeError:
155
- payload = None
156
- if isinstance(payload, dict):
157
- yield payload
168
+ cursor += 1
169
+ payload = _parse_jsonl_record_line_safely(
170
+ raw,
171
+ oversized_line_bytes=oversized_line_bytes,
172
+ )
173
+ yield cursor, payload
158
174
  start = newline_index + 1
159
175
  continue
160
176
  break
161
177
 
162
178
  if oversized:
163
- yield _oversized_event_placeholder(prefix=bytes(prefix), line_bytes=current_bytes)
179
+ cursor += 1
180
+ yield cursor, _oversized_event_placeholder(prefix=bytes(prefix), line_bytes=current_bytes)
164
181
  elif buffer:
165
182
  raw = bytes(buffer).strip()
166
- if raw:
167
- try:
168
- payload = json.loads(raw)
169
- except json.JSONDecodeError:
170
- payload = None
171
- if isinstance(payload, dict):
172
- yield payload
183
+ cursor += 1
184
+ payload = _parse_jsonl_record_line_safely(
185
+ raw,
186
+ oversized_line_bytes=oversized_line_bytes,
187
+ )
188
+ yield cursor, payload
173
189
 
174
190
 
175
191
  def _parse_jsonl_record_line_safely(
@@ -188,11 +204,69 @@ def _parse_jsonl_record_line_safely(
188
204
  )
189
205
  try:
190
206
  payload = json.loads(raw)
191
- except json.JSONDecodeError:
207
+ except (UnicodeDecodeError, json.JSONDecodeError):
192
208
  return None
193
209
  return payload if isinstance(payload, dict) else None
194
210
 
195
211
 
212
+ def _jsonl_line_count_cache_path(path: Path) -> Path:
213
+ return path.with_name(f".{path.name}{_JSONL_LINE_COUNT_CACHE_SUFFIX}")
214
+
215
+
216
+ def _jsonl_path_state(path: Path) -> tuple[int, int, int] | None:
217
+ if not path.exists():
218
+ return None
219
+ stat = path.stat()
220
+ return (
221
+ stat.st_ino,
222
+ getattr(stat, "st_mtime_ns", int(stat.st_mtime * 1_000_000_000)),
223
+ stat.st_size,
224
+ )
225
+
226
+
227
+ def _jsonl_line_count_from_cache(path: Path) -> int | None:
228
+ current_state = _jsonl_path_state(path)
229
+ if current_state is None:
230
+ return 0
231
+
232
+ cache_path = _jsonl_line_count_cache_path(path)
233
+ payload = read_json(cache_path, {})
234
+ if not isinstance(payload, dict):
235
+ payload = {}
236
+ raw_state = payload.get("state")
237
+ raw_total = payload.get("total")
238
+ if not isinstance(raw_state, (list, tuple)) or len(raw_state) != 3:
239
+ return None
240
+ try:
241
+ cached_state = tuple(int(item) for item in raw_state)
242
+ cached_total = int(raw_total)
243
+ except (TypeError, ValueError):
244
+ return None
245
+ if cached_total < 0:
246
+ return None
247
+ if cached_state == current_state:
248
+ return cached_total
249
+
250
+ # If the file only grew through append-only writes, count just the delta.
251
+ if cached_state[0] == current_state[0] and current_state[2] > cached_state[2]:
252
+ appended_count = 0
253
+ for relative_cursor, _payload in _iter_jsonl_records_from_offset_safely(
254
+ path,
255
+ start_offset=cached_state[2],
256
+ ):
257
+ appended_count = relative_cursor
258
+ total = cached_total + appended_count
259
+ write_json(
260
+ cache_path,
261
+ {
262
+ "state": list(current_state),
263
+ "total": total,
264
+ },
265
+ )
266
+ return total
267
+ return None
268
+
269
+
196
270
  def _tail_jsonl_records_safely(
197
271
  path: Path,
198
272
  *,
@@ -225,6 +299,9 @@ def _tail_jsonl_records_safely(
225
299
  def _count_jsonl_lines_fast(path: Path, *, chunk_size: int = 1024 * 1024) -> int:
226
300
  if not path.exists():
227
301
  return 0
302
+ cached_total = _jsonl_line_count_from_cache(path)
303
+ if cached_total is not None:
304
+ return cached_total
228
305
  total = 0
229
306
  last_byte = b""
230
307
  with path.open("rb") as handle:
@@ -235,9 +312,18 @@ def _count_jsonl_lines_fast(path: Path, *, chunk_size: int = 1024 * 1024) -> int
235
312
  total += chunk.count(b"\n")
236
313
  last_byte = chunk[-1:]
237
314
  if total == 0 and last_byte:
238
- return 1
239
- if last_byte not in {b"", b"\n"}:
315
+ total = 1
316
+ elif last_byte not in {b"", b"\n"}:
240
317
  total += 1
318
+ state = _jsonl_path_state(path)
319
+ if state is not None:
320
+ write_json(
321
+ _jsonl_line_count_cache_path(path),
322
+ {
323
+ "state": list(state),
324
+ "total": total,
325
+ },
326
+ )
241
327
  return total
242
328
 
243
329
 
@@ -284,13 +370,12 @@ def _iter_jsonl_records_from_offset_safely(
284
370
  return
285
371
  with path.open("rb") as handle:
286
372
  handle.seek(max(int(start_offset or 0), 0))
287
- for raw_line in handle:
373
+ for relative_cursor, raw_line in enumerate(handle, start=1):
288
374
  payload = _parse_jsonl_record_line_safely(
289
375
  raw_line,
290
376
  oversized_line_bytes=oversized_line_bytes,
291
377
  )
292
- if isinstance(payload, dict):
293
- yield payload
378
+ yield relative_cursor, payload
294
379
 
295
380
 
296
381
  class QuestService:
@@ -319,36 +404,71 @@ class QuestService:
319
404
  self._quest_projection_refresh_lock = threading.Lock()
320
405
  self._quest_projection_refresh_at: dict[str, float] = {}
321
406
 
407
+ def _configured_default_runner(self) -> str:
408
+ config = ConfigManager(self.home).load_named("config")
409
+ return self._resolve_enabled_runner_name(config.get("default_runner"))
410
+
411
+ def _resolve_enabled_runner_name(self, *candidates: Any) -> str:
412
+ runners = ConfigManager(self.home).load_runners_config()
413
+ seen: set[str] = set()
414
+ enabled: list[str] = []
415
+ for name, cfg in runners.items():
416
+ normalized = str(name or "").strip().lower()
417
+ if not normalized or normalized in seen:
418
+ continue
419
+ seen.add(normalized)
420
+ if isinstance(cfg, dict) and cfg.get("enabled") is not False:
421
+ enabled.append(normalized)
422
+
423
+ checked: set[str] = set()
424
+ for raw in [*candidates, "codex"]:
425
+ normalized = str(raw or "").strip().lower()
426
+ if not normalized or normalized in checked:
427
+ continue
428
+ checked.add(normalized)
429
+ cfg = runners.get(normalized)
430
+ if isinstance(cfg, dict) and cfg.get("enabled") is not False:
431
+ return normalized
432
+ return enabled[0] if enabled else "codex"
433
+
322
434
  def _quest_root(self, quest_id: str) -> Path:
323
435
  return self.quests_root / quest_id
324
436
 
437
+ def _require_initialized_quest_root(self, quest_id: str) -> Path:
438
+ quest_root = self._quest_root(quest_id)
439
+ if not quest_root.exists() or not self._quest_yaml_path(quest_root).exists():
440
+ raise FileNotFoundError(f"Unknown quest `{quest_id}`.")
441
+ return quest_root
442
+
325
443
  def _normalized_binding_sources(self, sources: list[Any] | None) -> list[str]:
326
444
  local_present = False
327
- external_source: str | None = None
445
+ external_sources: list[str] = []
446
+ external_index: dict[str, int] = {}
328
447
  for raw in sources or []:
329
448
  normalized = self._normalize_binding_source(raw)
330
449
  if not normalized:
331
450
  continue
332
- if normalized == "local:default":
333
- local_present = True
334
- continue
335
451
  parsed = parse_conversation_id(normalized)
336
452
  connector = str((parsed or {}).get("connector") or "").strip().lower()
337
- if connector == "local":
453
+ if normalized == "local:default" or connector == "local":
338
454
  local_present = True
339
455
  continue
340
- external_source = normalized
341
- if external_source:
342
- return ["local:default", external_source]
456
+ identity = conversation_identity_key(normalized)
457
+ existing_index = external_index.get(identity)
458
+ if existing_index is None:
459
+ external_index[identity] = len(external_sources)
460
+ external_sources.append(normalized)
461
+ else:
462
+ external_sources[existing_index] = normalized
343
463
  if local_present:
344
- return ["local:default"]
345
- return ["local:default"]
464
+ return ["local:default", *external_sources]
465
+ return external_sources
346
466
 
347
467
  def _binding_sources_payload(self, quest_root: Path) -> dict[str, list[str]]:
348
468
  bindings_path = quest_root / ".ds" / "bindings.json"
349
- payload = read_json(bindings_path, {"sources": ["local:default"]})
350
- raw_sources = payload.get("sources") if isinstance(payload, dict) else ["local:default"]
351
- sources = self._normalized_binding_sources(raw_sources if isinstance(raw_sources, list) else ["local:default"])
469
+ payload = read_json(bindings_path, {"sources": []})
470
+ raw_sources = payload.get("sources") if isinstance(payload, dict) else []
471
+ sources = self._normalized_binding_sources(raw_sources if isinstance(raw_sources, list) else [])
352
472
  return {"sources": sources}
353
473
 
354
474
  def preferred_locale(self, quest_root: Path | None = None) -> str:
@@ -394,11 +514,21 @@ class QuestService:
394
514
  if not isinstance(payload, dict):
395
515
  payload = {}
396
516
  normalized = dict(payload)
397
- normalized.setdefault("active_anchor", "baseline")
517
+ startup_contract = dict(normalized.get("startup_contract") or {}) if isinstance(normalized.get("startup_contract"), dict) else None
518
+ normalized.setdefault("startup_contract", startup_contract)
398
519
  normalized.setdefault("baseline_gate", "pending")
399
520
  normalized.setdefault("confirmed_baseline_ref", None)
400
521
  normalized.setdefault("requested_baseline_ref", None)
401
- normalized.setdefault("startup_contract", None)
522
+ active_anchor = str(normalized.get("active_anchor") or "").strip()
523
+ if not active_anchor:
524
+ normalized["active_anchor"] = default_active_anchor(startup_contract)
525
+ elif (
526
+ active_anchor == "baseline"
527
+ and str((startup_contract or {}).get("workspace_mode") or "").strip().lower() == "copilot"
528
+ and not isinstance(normalized.get("confirmed_baseline_ref"), dict)
529
+ and not isinstance(normalized.get("requested_baseline_ref"), dict)
530
+ ):
531
+ normalized["active_anchor"] = "scout"
402
532
  return normalized
403
533
 
404
534
  @staticmethod
@@ -876,7 +1006,7 @@ class QuestService:
876
1006
  if not artifacts_root.exists():
877
1007
  continue
878
1008
  for folder in sorted(artifacts_root.iterdir()):
879
- if not folder.is_dir():
1009
+ if not folder.is_dir() or folder.name == "graphs":
880
1010
  continue
881
1011
  for path in sorted(folder.glob("*.json")):
882
1012
  item = self._read_cached_json(path, {})
@@ -1509,16 +1639,18 @@ class QuestService:
1509
1639
  for artifact in recent_artifacts:
1510
1640
  payload = artifact.get("payload") if isinstance(artifact.get("payload"), dict) else {}
1511
1641
  artifact_path = artifact.get("path")
1642
+ artifact_kind = str(payload.get("kind") or artifact.get("kind") or "").strip()
1512
1643
  entries.append(
1513
1644
  {
1514
1645
  "id": f"artifact:{payload.get('artifact_id') or artifact_path}",
1515
1646
  "kind": "artifact",
1516
- "title": str(payload.get("artifact_id") or artifact.get("kind") or "artifact"),
1647
+ "title": str(payload.get("title") or payload.get("artifact_id") or artifact.get("kind") or "artifact"),
1517
1648
  "summary": payload.get("summary") or payload.get("message") or payload.get("reason") or "Artifact updated.",
1518
1649
  "status": payload.get("status"),
1519
1650
  "reason": payload.get("reason"),
1520
1651
  "created_at": payload.get("updated_at") or payload.get("created_at"),
1521
1652
  "paths": list((payload.get("paths") or {}).values()) + ([str(artifact_path)] if artifact_path else []),
1653
+ "stage_key": "science" if artifact_kind.startswith("science.") else payload.get("stage_key"),
1522
1654
  }
1523
1655
  )
1524
1656
  add_file(str(artifact_path) if artifact_path else None, source="artifact")
@@ -1826,6 +1958,37 @@ class QuestService:
1826
1958
  best_root = paper_root
1827
1959
  return best_root
1828
1960
 
1961
+ @staticmethod
1962
+ def _paper_line_state_from_root(paper_root: Path) -> dict[str, Any]:
1963
+ path = paper_root / "paper_line_state.json"
1964
+ payload = read_json(path, {})
1965
+ return payload if isinstance(payload, dict) else {}
1966
+
1967
+ @staticmethod
1968
+ def _filter_paper_evidence_items(
1969
+ items: list[dict[str, Any]],
1970
+ *,
1971
+ selected_outline_ref: str | None = None,
1972
+ paper_line_id: str | None = None,
1973
+ ) -> list[dict[str, Any]]:
1974
+ normalized_outline = str(selected_outline_ref or "").strip() or None
1975
+ normalized_line = str(paper_line_id or "").strip() or None
1976
+ filtered: list[dict[str, Any]] = []
1977
+ for item in items:
1978
+ if not isinstance(item, dict):
1979
+ continue
1980
+ item_outline = str(item.get("selected_outline_ref") or "").strip() or None
1981
+ item_line = str(item.get("paper_line_id") or "").strip() or None
1982
+ if normalized_line:
1983
+ if item_line and item_line != normalized_line:
1984
+ continue
1985
+ if not item_line and normalized_outline and item_outline and item_outline != normalized_outline:
1986
+ continue
1987
+ elif normalized_outline and item_outline and item_outline != normalized_outline:
1988
+ continue
1989
+ filtered.append(dict(item))
1990
+ return filtered
1991
+
1829
1992
  def _outline_record_from_paper_root(self, paper_root: Path) -> dict[str, Any]:
1830
1993
  outline_root = paper_root / "outline"
1831
1994
  manifest_path = outline_root / "manifest.json"
@@ -1887,52 +2050,49 @@ class QuestService:
1887
2050
  return payload if isinstance(payload, dict) else {}
1888
2051
 
1889
2052
  def _paper_evidence_payload(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
1890
- best_payload: dict[str, Any] | None = None
1891
- best_rank: tuple[str, float] = ("", -1.0)
1892
- for candidate in self._snapshot_workspace_candidates(quest_root, workspace_root):
1893
- paper_root = candidate / "paper"
1894
- ledger_json_path = paper_root / "evidence_ledger.json"
1895
- if not ledger_json_path.exists():
1896
- continue
1897
- payload = read_json(ledger_json_path, {})
1898
- if not isinstance(payload, dict) or not payload:
1899
- continue
1900
- items = [dict(item) for item in (payload.get("items") or []) if isinstance(item, dict)]
1901
- latest = max(
1902
- self._path_mtime(ledger_json_path),
1903
- self._path_mtime(paper_root / "evidence_ledger.md"),
1904
- self._path_mtime(paper_root),
1905
- )
1906
- rank = (str(payload.get("updated_at") or payload.get("created_at") or ""), latest)
1907
- if rank < best_rank:
1908
- continue
1909
- best_rank = rank
1910
- best_payload = {
1911
- "paper_root": str(paper_root),
1912
- "workspace_root": str(paper_root.parent),
1913
- "selected_outline_ref": str(payload.get("selected_outline_ref") or "").strip() or None,
1914
- "item_count": len(items),
1915
- "main_text_ready_count": sum(
1916
- 1
1917
- for item in items
1918
- if str(item.get("paper_role") or "").strip() == "main_text"
1919
- and str(item.get("status") or "").strip().lower() in {"ready", "completed", "analyzed", "written", "recorded", "supported"}
1920
- ),
1921
- "appendix_item_count": sum(
1922
- 1 for item in items if str(item.get("paper_role") or "").strip() == "appendix"
1923
- ),
1924
- "unmapped_item_count": sum(
1925
- 1
1926
- for item in items
1927
- if not str(item.get("section_id") or "").strip() or not str(item.get("paper_role") or "").strip()
1928
- ),
1929
- "items": items[:40],
1930
- "paths": {
1931
- "ledger_json": str(ledger_json_path),
1932
- "ledger_md": str(paper_root / "evidence_ledger.md") if (paper_root / "evidence_ledger.md").exists() else None,
1933
- },
1934
- }
1935
- return best_payload
2053
+ paper_root = self._best_paper_root(quest_root, workspace_root)
2054
+ if paper_root is None:
2055
+ return None
2056
+ ledger_json_path = paper_root / "evidence_ledger.json"
2057
+ if not ledger_json_path.exists():
2058
+ return None
2059
+ payload = read_json(ledger_json_path, {})
2060
+ if not isinstance(payload, dict) or not payload:
2061
+ return None
2062
+ selected_outline_ref = str(payload.get("selected_outline_ref") or "").strip() or None
2063
+ line_state = self._paper_line_state_from_root(paper_root)
2064
+ paper_line_id = str(line_state.get("paper_line_id") or "").strip() or None
2065
+ items = self._filter_paper_evidence_items(
2066
+ [dict(item) for item in (payload.get("items") or []) if isinstance(item, dict)],
2067
+ selected_outline_ref=selected_outline_ref,
2068
+ paper_line_id=paper_line_id,
2069
+ )
2070
+ return {
2071
+ "paper_root": str(paper_root),
2072
+ "workspace_root": str(paper_root.parent),
2073
+ "selected_outline_ref": selected_outline_ref,
2074
+ "paper_line_id": paper_line_id,
2075
+ "item_count": len(items),
2076
+ "main_text_ready_count": sum(
2077
+ 1
2078
+ for item in items
2079
+ if str(item.get("paper_role") or "").strip() == "main_text"
2080
+ and str(item.get("status") or "").strip().lower() in {"ready", "completed", "analyzed", "written", "recorded", "supported"}
2081
+ ),
2082
+ "appendix_item_count": sum(
2083
+ 1 for item in items if str(item.get("paper_role") or "").strip() == "appendix"
2084
+ ),
2085
+ "unmapped_item_count": sum(
2086
+ 1
2087
+ for item in items
2088
+ if not str(item.get("section_id") or "").strip() or not str(item.get("paper_role") or "").strip()
2089
+ ),
2090
+ "items": items[:40],
2091
+ "paths": {
2092
+ "ledger_json": str(ledger_json_path),
2093
+ "ledger_md": str(paper_root / "evidence_ledger.md") if (paper_root / "evidence_ledger.md").exists() else None,
2094
+ },
2095
+ }
1936
2096
 
1937
2097
  def _paper_contract_payload(self, quest_root: Path, workspace_root: Path) -> dict[str, Any] | None:
1938
2098
  paper_root = self._best_paper_root(quest_root, workspace_root)
@@ -1952,6 +2112,7 @@ class QuestService:
1952
2112
  bundle_manifest = bundle_manifest if isinstance(bundle_manifest, dict) else {}
1953
2113
  experiment_matrix_path = paper_root / "paper_experiment_matrix.md"
1954
2114
  experiment_matrix_json_path = paper_root / "paper_experiment_matrix.json"
2115
+ manuscript_coverage_path = paper_root / "manuscript_coverage.json"
1955
2116
  claim_map_path = paper_root / "claim_evidence_map.json"
1956
2117
  paper_line_state_path = paper_root / "paper_line_state.json"
1957
2118
  evidence_ledger = self._paper_evidence_payload(quest_root, workspace_root)
@@ -2002,11 +2163,14 @@ class QuestService:
2002
2163
  return {
2003
2164
  "paper_root": str(paper_root),
2004
2165
  "workspace_root": str(paper_root.parent),
2166
+ "paper_line_id": str(self._paper_line_state_from_root(paper_root).get("paper_line_id") or "").strip() or None,
2005
2167
  "paper_branch": str(bundle_manifest.get("paper_branch") or "").strip() or current_branch(paper_root.parent),
2006
2168
  "source_branch": str(bundle_manifest.get("source_branch") or "").strip() or None,
2007
2169
  "selected_outline_ref": str(selected_outline.get("outline_id") or bundle_manifest.get("selected_outline_ref") or "").strip() or None,
2008
2170
  "title": str(selected_outline.get("title") or bundle_manifest.get("title") or "").strip() or None,
2009
2171
  "story": str(selected_outline.get("story") or "").strip() or None,
2172
+ "paper_view": selected_outline.get("paper_view") if isinstance(selected_outline.get("paper_view"), dict) else None,
2173
+ "evidence_view": selected_outline.get("evidence_view") if isinstance(selected_outline.get("evidence_view"), dict) else None,
2010
2174
  "research_questions": detailed_outline.get("research_questions") if isinstance(detailed_outline.get("research_questions"), list) else [],
2011
2175
  "experimental_designs": detailed_outline.get("experimental_designs") if isinstance(detailed_outline.get("experimental_designs"), list) else [],
2012
2176
  "contributions": detailed_outline.get("contributions") if isinstance(detailed_outline.get("contributions"), list) else [],
@@ -2024,6 +2188,7 @@ class QuestService:
2024
2188
  "outline_manifest": str(outline_manifest_path) if outline_manifest_path.exists() else None,
2025
2189
  "experiment_matrix": str(experiment_matrix_path) if experiment_matrix_path.exists() else None,
2026
2190
  "experiment_matrix_json": str(experiment_matrix_json_path) if experiment_matrix_json_path.exists() else None,
2191
+ "manuscript_coverage": str(manuscript_coverage_path) if manuscript_coverage_path.exists() else None,
2027
2192
  "bundle_manifest": str(bundle_manifest_path) if bundle_manifest_path.exists() else None,
2028
2193
  "claim_evidence_map": str(claim_map_path) if claim_map_path.exists() else None,
2029
2194
  "paper_line_state": str(paper_line_state_path) if paper_line_state_path.exists() else None,
@@ -2417,11 +2582,25 @@ class QuestService:
2417
2582
  evidence_items = [
2418
2583
  dict(item) for item in ((paper_evidence or {}).get("items") or []) if isinstance(item, dict)
2419
2584
  ]
2420
- ledger_by_item = {
2421
- str(item.get("item_id") or "").strip(): item
2422
- for item in evidence_items
2423
- if str(item.get("item_id") or "").strip()
2424
- }
2585
+ ledger_by_item: dict[str, list[dict[str, Any]]] = {}
2586
+ for item in evidence_items:
2587
+ item_id = str(item.get("item_id") or "").strip()
2588
+ if item_id:
2589
+ ledger_by_item.setdefault(item_id, []).append(item)
2590
+
2591
+ def ready_ledger_item(item_id: str) -> dict[str, Any] | None:
2592
+ candidates = ledger_by_item.get(item_id) or []
2593
+ ready_statuses = {"ready", "completed", "analyzed", "written", "recorded", "supported"}
2594
+ ready = [
2595
+ item
2596
+ for item in candidates
2597
+ if str(item.get("status") or "").strip().lower() in ready_statuses
2598
+ ]
2599
+ if ready:
2600
+ main = [item for item in ready if str(item.get("paper_role") or "").strip() == "main_text"]
2601
+ return main[0] if main else ready[0]
2602
+ return candidates[0] if candidates else None
2603
+
2425
2604
  unresolved_required_items: list[dict[str, Any]] = []
2426
2605
  ready_section_count = 0
2427
2606
  for section in paper_contract.get("sections") or []:
@@ -2430,7 +2609,7 @@ class QuestService:
2430
2609
  required_items = [str(item).strip() for item in (section.get("required_items") or []) if str(item).strip()]
2431
2610
  section_ready = True
2432
2611
  for item_id in required_items:
2433
- ledger_item = ledger_by_item.get(item_id)
2612
+ ledger_item = ready_ledger_item(item_id)
2434
2613
  status = str((ledger_item or {}).get("status") or "").strip().lower()
2435
2614
  if status not in {"ready", "completed", "analyzed", "written", "recorded", "supported"}:
2436
2615
  unresolved_required_items.append(
@@ -2517,6 +2696,18 @@ class QuestService:
2517
2696
  if isinstance(paper_contract.get("bundle_manifest"), dict)
2518
2697
  else {}
2519
2698
  )
2699
+ package_type = str(bundle_manifest.get("package_type") or "draft_checkpoint").strip().lower().replace("-", "_")
2700
+ if package_type in {"", "draft", "memo", "checkpoint", "paper_memo"}:
2701
+ package_type = "draft_checkpoint"
2702
+ elif package_type in {"review", "review_bundle"}:
2703
+ package_type = "review_package"
2704
+ elif package_type in {"final", "final_bundle", "submission", "submission_bundle"}:
2705
+ package_type = "submission_package"
2706
+ elif package_type not in {"draft_checkpoint", "review_package", "submission_package"}:
2707
+ package_type = "draft_checkpoint"
2708
+ coverage_path = str(((paper_contract.get("paths") or {}).get("manuscript_coverage") or "")).strip()
2709
+ manuscript_coverage = read_json(Path(coverage_path), {}) if coverage_path else {}
2710
+ manuscript_coverage = manuscript_coverage if isinstance(manuscript_coverage, dict) else {}
2520
2711
  submission_checklist_path = str(((paper_contract.get("paths") or {}).get("submission_checklist") or "")).strip()
2521
2712
  submission_checklist = read_json(Path(submission_checklist_path), {}) if submission_checklist_path else {}
2522
2713
  submission_checklist = submission_checklist if isinstance(submission_checklist, dict) else {}
@@ -2530,9 +2721,23 @@ class QuestService:
2530
2721
  closure_state = "bundle_not_ready"
2531
2722
  delivery_state = "not_ready"
2532
2723
  keep_bundle_fixed_by_default = False
2533
- if bundle_status == "present":
2724
+ evidence_ready = contract_ok
2725
+ analysis_ready = writing_ready
2726
+ academic_outline_ready = bool(manuscript_coverage.get("academic_outline_ready"))
2727
+ analysis_plan_ready = bool(manuscript_coverage.get("analysis_plan_ready"))
2728
+ language_firewall_ok = bool(manuscript_coverage.get("language_firewall_ok"))
2729
+ draft_checkpoint_ready = bool(active_line.get("draft_checkpoint_ready")) or draft_status == "present" or bundle_status == "present"
2730
+ manuscript_ready = bool(active_line.get("manuscript_ready")) or bool(manuscript_coverage.get("manuscript_ready"))
2731
+ submission_ready = bool(active_line.get("submission_ready")) or bool(manuscript_coverage.get("submission_ready"))
2732
+ if submission_ready:
2534
2733
  closure_state = "delivery_ready"
2535
- delivery_state = "bundle_ready"
2734
+ delivery_state = "submission_ready"
2735
+ elif manuscript_ready:
2736
+ closure_state = "review_before_submission"
2737
+ delivery_state = "manuscript_ready"
2738
+ elif draft_checkpoint_ready:
2739
+ closure_state = "draft_checkpoint_continue_writing"
2740
+ delivery_state = "draft_checkpoint_ready"
2536
2741
  if delivered_at or "delivered" in overall_status:
2537
2742
  delivery_state = "delivered"
2538
2743
  closure_state = "delivered_continue_research" if "continue" in overall_status else "delivered_parked"
@@ -2541,15 +2746,30 @@ class QuestService:
2541
2746
  if unmapped_completed_items:
2542
2747
  recommended_next_stage = "write"
2543
2748
  recommended_action = "sync_paper_contract"
2749
+ elif manuscript_coverage and not academic_outline_ready:
2750
+ recommended_next_stage = "write"
2751
+ recommended_action = "repair_academic_outline_with_paper_outline"
2752
+ elif manuscript_coverage and not analysis_plan_ready:
2753
+ recommended_next_stage = "write"
2754
+ recommended_action = "repair_analysis_plan_with_paper_outline"
2544
2755
  elif unresolved_required_items or blocking_pending_slices:
2545
2756
  recommended_next_stage = "analysis-campaign"
2546
2757
  recommended_action = "complete_required_supplementary"
2758
+ elif manuscript_coverage and not language_firewall_ok and draft_checkpoint_ready:
2759
+ recommended_next_stage = "write"
2760
+ recommended_action = "repair_manuscript_language"
2547
2761
  elif draft_status != "present":
2548
2762
  recommended_next_stage = "write"
2549
2763
  recommended_action = "draft_paper"
2550
2764
  elif bundle_status != "present":
2551
2765
  recommended_next_stage = "write"
2552
- recommended_action = "prepare_bundle"
2766
+ recommended_action = "submit_draft_checkpoint"
2767
+ elif not manuscript_ready:
2768
+ recommended_next_stage = "write"
2769
+ recommended_action = "expand_manuscript_and_figures"
2770
+ elif not submission_ready:
2771
+ recommended_next_stage = "review"
2772
+ recommended_action = "prepare_submission_package"
2553
2773
  else:
2554
2774
  recommended_next_stage = "finalize"
2555
2775
  recommended_action = "finalize_paper_line"
@@ -2568,7 +2788,16 @@ class QuestService:
2568
2788
  "selected_outline_ref": selected_outline_ref,
2569
2789
  "contract_ok": contract_ok,
2570
2790
  "writing_ready": writing_ready,
2571
- "finalize_ready": writing_ready and bundle_status == "present",
2791
+ "evidence_ready": evidence_ready,
2792
+ "analysis_ready": analysis_ready,
2793
+ "academic_outline_ready": academic_outline_ready,
2794
+ "analysis_plan_ready": analysis_plan_ready,
2795
+ "language_firewall_ok": language_firewall_ok,
2796
+ "draft_checkpoint_ready": draft_checkpoint_ready,
2797
+ "manuscript_ready": manuscript_ready,
2798
+ "submission_ready": submission_ready,
2799
+ "finalize_ready": submission_ready,
2800
+ "package_type": package_type,
2572
2801
  "closure_state": closure_state,
2573
2802
  "delivery_state": delivery_state,
2574
2803
  "delivered_at": delivered_at,
@@ -2597,6 +2826,27 @@ class QuestService:
2597
2826
  "draft_status": draft_status,
2598
2827
  "bundle_status": bundle_status,
2599
2828
  "blocking_reasons": blocking_reasons,
2829
+ "manuscript_blocking_reasons": list(
2830
+ active_line.get("manuscript_blocking_reasons")
2831
+ or manuscript_coverage.get("manuscript_blockers")
2832
+ or []
2833
+ ),
2834
+ "manuscript_warning_reasons": list(
2835
+ active_line.get("manuscript_warning_reasons")
2836
+ or manuscript_coverage.get("manuscript_warnings")
2837
+ or []
2838
+ ),
2839
+ "submission_blocking_reasons": list(
2840
+ active_line.get("submission_blocking_reasons")
2841
+ or manuscript_coverage.get("submission_blockers")
2842
+ or []
2843
+ ),
2844
+ "submission_warning_reasons": list(
2845
+ active_line.get("submission_warning_reasons")
2846
+ or manuscript_coverage.get("submission_warnings")
2847
+ or []
2848
+ ),
2849
+ "manuscript_coverage": manuscript_coverage or None,
2600
2850
  "recommended_next_stage": recommended_next_stage,
2601
2851
  "recommended_action": recommended_action,
2602
2852
  "unresolved_required_items": unresolved_required_items[:12],
@@ -2629,6 +2879,40 @@ class QuestService:
2629
2879
  return text
2630
2880
  return text.zfill(_NUMERIC_QUEST_ID_PAD_WIDTH)
2631
2881
 
2882
+ @staticmethod
2883
+ def _parse_reserved_numeric_quest_id(value: str | None) -> int | None:
2884
+ numeric_value = QuestService._parse_numeric_quest_id(value)
2885
+ if numeric_value is not None:
2886
+ return numeric_value
2887
+ raw = str(value or "").strip()
2888
+ match = _SYSTEM_NUMERIC_QUEST_ID_PATTERN.fullmatch(raw)
2889
+ if match is None:
2890
+ return None
2891
+ return QuestService._parse_numeric_quest_id(match.group("number"))
2892
+
2893
+ @staticmethod
2894
+ def _quest_class_for(
2895
+ *,
2896
+ quest_id: str | None,
2897
+ startup_contract: dict[str, Any] | None = None,
2898
+ ) -> str:
2899
+ normalized_quest_id = str(quest_id or "").strip()
2900
+ match = _SYSTEM_NUMERIC_QUEST_ID_PATTERN.fullmatch(normalized_quest_id)
2901
+ if match is not None:
2902
+ prefix = str(match.group("prefix") or "").strip().upper()
2903
+ if prefix == "S":
2904
+ return "settings"
2905
+ if prefix == "B":
2906
+ return "benchstore"
2907
+
2908
+ contract = startup_contract if isinstance(startup_contract, dict) else {}
2909
+ custom_profile = str(contract.get("custom_profile") or "").strip().lower()
2910
+ if custom_profile in {"admin_ops", "settings_issue"}:
2911
+ return "settings"
2912
+ if isinstance(contract.get("benchstore_context"), dict) or isinstance(contract.get("start_setup_session"), dict):
2913
+ return "benchstore"
2914
+ return "research"
2915
+
2632
2916
  @contextmanager
2633
2917
  def _quest_id_state_lock(self):
2634
2918
  lock_path = self._quest_id_lock_path()
@@ -2714,7 +2998,7 @@ class QuestService:
2714
2998
  return self._format_numeric_quest_id(next_numeric_id)
2715
2999
 
2716
3000
  def _reserve_numeric_quest_id(self, quest_id: str) -> None:
2717
- numeric_value = self._parse_numeric_quest_id(quest_id)
3001
+ numeric_value = self._parse_reserved_numeric_quest_id(quest_id)
2718
3002
  if numeric_value is None:
2719
3003
  return
2720
3004
  with self._quest_id_state_lock():
@@ -2724,24 +3008,28 @@ class QuestService:
2724
3008
  self._write_quest_id_state_locked(next_numeric_id)
2725
3009
 
2726
3010
  def _normalize_quest_id(self, quest_id: str | None) -> tuple[str, bool]:
2727
- raw = str(quest_id or "").strip().lower()
3011
+ raw = str(quest_id or "").strip()
2728
3012
  if not raw:
2729
3013
  return self._allocate_next_numeric_quest_id(), True
2730
- slug = re.sub(r"[^a-z0-9._-]+", "-", raw).strip("._-")
3014
+ slug = re.sub(r"[^A-Za-z0-9._-]+", "-", raw).strip("._-")
2731
3015
  if not slug:
2732
3016
  return self._allocate_next_numeric_quest_id(), True
3017
+ system_match = _SYSTEM_NUMERIC_QUEST_ID_PATTERN.fullmatch(slug)
3018
+ if system_match is not None:
3019
+ slug = f"{str(system_match.group('prefix') or '').upper()}-{system_match.group('number')}"
2733
3020
  return slug[:80], False
2734
3021
 
2735
3022
  def create(
2736
3023
  self,
2737
3024
  goal: str,
2738
3025
  quest_id: str | None = None,
2739
- runner: str = "codex",
3026
+ runner: str | None = None,
2740
3027
  title: str | None = None,
2741
3028
  *,
2742
3029
  requested_baseline_ref: dict[str, Any] | None = None,
2743
3030
  startup_contract: dict[str, Any] | None = None,
2744
3031
  ) -> dict:
3032
+ resolved_runner = str(runner or self._configured_default_runner()).strip().lower() or "codex"
2745
3033
  quest_id, auto_generated = self._normalize_quest_id(quest_id)
2746
3034
  quest_root = self._quest_root(quest_id)
2747
3035
  if quest_root.exists():
@@ -2757,7 +3045,7 @@ class QuestService:
2757
3045
  quest_id,
2758
3046
  goal,
2759
3047
  quest_root,
2760
- runner,
3048
+ resolved_runner,
2761
3049
  title=title,
2762
3050
  requested_baseline_ref=dict(requested_baseline_ref) if isinstance(requested_baseline_ref, dict) else None,
2763
3051
  startup_contract=dict(startup_contract) if isinstance(startup_contract, dict) else None,
@@ -2782,6 +3070,87 @@ class QuestService:
2782
3070
  self._initialize_runtime_files(quest_root)
2783
3071
  return self.snapshot(quest_id)
2784
3072
 
3073
+ def repair_orphaned_quest_scaffold(
3074
+ self,
3075
+ quest_id: str,
3076
+ *,
3077
+ title: str | None = None,
3078
+ goal: str | None = None,
3079
+ runner: str | None = None,
3080
+ ) -> dict[str, Any]:
3081
+ resolved_runner = str(runner or self._configured_default_runner()).strip().lower() or "codex"
3082
+ quest_root = self._quest_root(quest_id)
3083
+ if not quest_root.exists():
3084
+ raise FileNotFoundError(f"Unknown quest `{quest_id}`.")
3085
+ quest_yaml_path = self._quest_yaml_path(quest_root)
3086
+ if quest_yaml_path.exists():
3087
+ raise FileExistsError(f"Quest `{quest_id}` already has a scaffold.")
3088
+
3089
+ restored_goal = str(goal or f"Recovered quest {quest_id}").strip() or f"Recovered quest {quest_id}"
3090
+ restored_title = str(title or quest_id).strip() or quest_id
3091
+
3092
+ for relative in QUEST_DIRECTORIES:
3093
+ ensure_dir(quest_root / relative)
3094
+
3095
+ write_yaml(
3096
+ quest_yaml_path,
3097
+ initial_quest_yaml(
3098
+ quest_id,
3099
+ restored_goal,
3100
+ quest_root,
3101
+ resolved_runner,
3102
+ title=restored_title,
3103
+ ),
3104
+ )
3105
+ write_text(
3106
+ quest_root / "brief.md",
3107
+ "\n".join(
3108
+ [
3109
+ "# Quest Brief",
3110
+ "",
3111
+ "## Recovery Note",
3112
+ "",
3113
+ "This quest scaffold was recreated because the core quest files were missing.",
3114
+ "Existing runtime traces under `.ds/` were preserved.",
3115
+ "",
3116
+ "## Goal",
3117
+ "",
3118
+ restored_goal,
3119
+ "",
3120
+ ]
3121
+ ),
3122
+ )
3123
+ write_text(
3124
+ quest_root / "plan.md",
3125
+ "\n".join(
3126
+ [
3127
+ "# Plan",
3128
+ "",
3129
+ "- [ ] Inspect preserved runtime traces under `.ds/`",
3130
+ "- [ ] Re-establish the baseline context",
3131
+ "- [ ] Recreate any missing durable files or artifacts",
3132
+ "",
3133
+ ]
3134
+ ),
3135
+ )
3136
+ write_text(
3137
+ quest_root / "status.md",
3138
+ "# Status\n\nRecovered scaffold. Review preserved runtime state before continuing.\n",
3139
+ )
3140
+ write_text(
3141
+ quest_root / "SUMMARY.md",
3142
+ "# Summary\n\nRecovered quest scaffold. Original top-level quest files were missing.\n",
3143
+ )
3144
+ write_text(quest_root / ".gitignore", gitignore())
3145
+ self._write_active_user_requirements(
3146
+ quest_root,
3147
+ latest_requirement=None,
3148
+ )
3149
+ if not (quest_root / ".git").exists():
3150
+ init_repo(quest_root)
3151
+ self._initialize_runtime_files(quest_root)
3152
+ return self.snapshot(quest_id)
3153
+
2785
3154
  def list_quests(self) -> list[dict]:
2786
3155
  items: list[dict] = []
2787
3156
  if not self.quests_root.exists():
@@ -2879,7 +3248,7 @@ class QuestService:
2879
3248
  )
2880
3249
 
2881
3250
  def summary_compact(self, quest_id: str) -> dict[str, Any]:
2882
- quest_root = self._quest_root(quest_id)
3251
+ quest_root = self._require_initialized_quest_root(quest_id)
2883
3252
  cache_key = f"compact:{self._cache_key_for_path(quest_root)}"
2884
3253
  state = self._compact_summary_state(quest_root)
2885
3254
  with self._snapshot_cache_lock:
@@ -2935,9 +3304,16 @@ class QuestService:
2935
3304
 
2936
3305
  bash_summary = BashExecService(self.home).summary(quest_root)
2937
3306
  interaction_watchdog = self.artifact_interaction_watchdog_status(quest_root)
3307
+ quest_class = self._quest_class_for(
3308
+ quest_id=str(quest_yaml.get("quest_id") or quest_id).strip(),
3309
+ startup_contract=quest_yaml.get("startup_contract") if isinstance(quest_yaml.get("startup_contract"), dict) else None,
3310
+ )
3311
+ workspace_mode = str(research_state.get("workspace_mode") or "quest").strip().lower() or "quest"
3312
+ listed_in_projects = quest_class == "research" and workspace_mode in {"copilot", "autonomous"}
2938
3313
  payload = {
2939
3314
  "quest_id": quest_yaml.get("quest_id", quest_id),
2940
3315
  "title": quest_yaml.get("title", quest_id),
3316
+ "goal": quest_yaml.get("goal"),
2941
3317
  "quest_root": str(quest_root.resolve()),
2942
3318
  "status": runtime_state.get("display_status") or runtime_state.get("status") or quest_yaml.get("status", "idle"),
2943
3319
  "runtime_status": runtime_state.get("status") or quest_yaml.get("status", "idle"),
@@ -2953,7 +3329,9 @@ class QuestService:
2953
3329
  "research_head_worktree_root": research_state.get("research_head_worktree_root"),
2954
3330
  "current_workspace_branch": research_state.get("current_workspace_branch"),
2955
3331
  "current_workspace_root": research_state.get("current_workspace_root"),
2956
- "workspace_mode": research_state.get("workspace_mode") or "quest",
3332
+ "workspace_mode": workspace_mode,
3333
+ "quest_class": quest_class,
3334
+ "listed_in_projects": listed_in_projects,
2957
3335
  "active_idea_id": research_state.get("active_idea_id"),
2958
3336
  "active_baseline_id": active_baseline_id,
2959
3337
  "active_baseline_variant_id": active_baseline_variant_id,
@@ -2962,6 +3340,7 @@ class QuestService:
2962
3340
  "continuation_anchor": runtime_state.get("continuation_anchor"),
2963
3341
  "continuation_reason": runtime_state.get("continuation_reason"),
2964
3342
  "continuation_updated_at": runtime_state.get("continuation_updated_at"),
3343
+ "waiting_notice": runtime_state.get("waiting_notice"),
2965
3344
  "last_resume_source": runtime_state.get("last_resume_source"),
2966
3345
  "last_resume_at": runtime_state.get("last_resume_at"),
2967
3346
  "last_recovery_abandoned_run_id": runtime_state.get("last_recovery_abandoned_run_id"),
@@ -3056,18 +3435,18 @@ class QuestService:
3056
3435
  self._jsonl_tail_cache.pop(cache_key, None)
3057
3436
  return [], 0, False
3058
3437
  if normalized_limit <= 0:
3059
- total = sum(1 for _ in _iter_jsonl_records_safely(path))
3438
+ total = _count_jsonl_lines_fast(path)
3060
3439
  return [], total, False
3061
3440
 
3062
3441
  if before is not None:
3063
- stop_cursor = max(int(before) - 1, 0)
3064
3442
  window: deque[tuple[int, dict[str, Any]]] = deque(maxlen=normalized_limit)
3065
3443
  total = 0
3066
- for payload in _iter_jsonl_records_safely(path):
3067
- total += 1
3068
- if total >= before:
3444
+ for cursor, payload in _iter_jsonl_records_safely(path):
3445
+ total = cursor
3446
+ if cursor >= before:
3069
3447
  break
3070
- window.append((total, payload))
3448
+ if isinstance(payload, dict):
3449
+ window.append((cursor, payload))
3071
3450
  has_more = bool(window and window[0][0] > 1)
3072
3451
  return list(window), total, has_more
3073
3452
 
@@ -3087,6 +3466,17 @@ class QuestService:
3087
3466
  window = cached_records[-normalized_limit:]
3088
3467
  has_more = cached_total > len(window)
3089
3468
  return window, cached_total, has_more
3469
+ if cached_limit < normalized_limit:
3470
+ window, total = _tail_jsonl_records_safely(path, limit=normalized_limit)
3471
+ with self._jsonl_cache_lock:
3472
+ self._jsonl_tail_cache[cache_key] = {
3473
+ "state": state,
3474
+ "limit": normalized_limit,
3475
+ "total": total,
3476
+ "records": list(window),
3477
+ }
3478
+ has_more = total > len(window)
3479
+ return list(window), total, has_more
3090
3480
 
3091
3481
  if (
3092
3482
  cached_tail
@@ -3110,11 +3500,11 @@ class QuestService:
3110
3500
  )
3111
3501
  )
3112
3502
  if appended_records:
3113
- next_cursor = cached_total + 1
3114
- for payload in appended_records:
3115
- window.append((next_cursor, payload))
3116
- next_cursor += 1
3117
- total = cached_total + len(appended_records)
3503
+ total = cached_total
3504
+ for relative_cursor, payload in appended_records:
3505
+ total = cached_total + relative_cursor
3506
+ if isinstance(payload, dict):
3507
+ window.append((total, payload))
3118
3508
  else:
3119
3509
  total = cached_total
3120
3510
  stored_records = list(window)
@@ -3125,6 +3515,13 @@ class QuestService:
3125
3515
  "total": total,
3126
3516
  "records": stored_records,
3127
3517
  }
3518
+ write_json(
3519
+ _jsonl_line_count_cache_path(path),
3520
+ {
3521
+ "state": list(state),
3522
+ "total": total,
3523
+ },
3524
+ )
3128
3525
  selected = stored_records[-normalized_limit:]
3129
3526
  has_more = total > len(selected)
3130
3527
  return selected, total, has_more
@@ -3144,12 +3541,14 @@ class QuestService:
3144
3541
  total = 0
3145
3542
  saw_more = False
3146
3543
  normalized_after = max(int(after or 0), 0)
3147
- for payload in _iter_jsonl_records_safely(path):
3148
- total += 1
3149
- if total <= normalized_after:
3544
+ for cursor, payload in _iter_jsonl_records_safely(path):
3545
+ total = cursor
3546
+ if cursor <= normalized_after:
3547
+ continue
3548
+ if not isinstance(payload, dict):
3150
3549
  continue
3151
3550
  if len(collected) < normalized_limit:
3152
- collected.append((total, payload))
3551
+ collected.append((cursor, payload))
3153
3552
  continue
3154
3553
  saw_more = True
3155
3554
  return collected, total, saw_more
@@ -3254,7 +3653,7 @@ class QuestService:
3254
3653
  return self._snapshot(quest_id)
3255
3654
 
3256
3655
  def _snapshot(self, quest_id: str) -> dict:
3257
- quest_root = self._quest_root(quest_id)
3656
+ quest_root = self._require_initialized_quest_root(quest_id)
3258
3657
  cache_key = f"snapshot:{self._cache_key_for_path(quest_root)}"
3259
3658
  state = self._snapshot_state(quest_root)
3260
3659
  with self._snapshot_cache_lock:
@@ -3428,6 +3827,7 @@ class QuestService:
3428
3827
  payload = {
3429
3828
  "quest_id": quest_yaml.get("quest_id", quest_id),
3430
3829
  "title": quest_yaml.get("title", quest_id),
3830
+ "goal": quest_yaml.get("goal"),
3431
3831
  "quest_root": str(quest_root.resolve()),
3432
3832
  "status": runtime_state.get("display_status") or runtime_state.get("status") or quest_yaml.get("status", "idle"),
3433
3833
  "runtime_status": runtime_state.get("status") or quest_yaml.get("status", "idle"),
@@ -3466,6 +3866,7 @@ class QuestService:
3466
3866
  "continuation_anchor": runtime_state.get("continuation_anchor"),
3467
3867
  "continuation_reason": runtime_state.get("continuation_reason"),
3468
3868
  "continuation_updated_at": runtime_state.get("continuation_updated_at"),
3869
+ "waiting_notice": runtime_state.get("waiting_notice"),
3469
3870
  "last_resume_source": runtime_state.get("last_resume_source"),
3470
3871
  "last_resume_at": runtime_state.get("last_resume_at"),
3471
3872
  "last_recovery_abandoned_run_id": runtime_state.get("last_recovery_abandoned_run_id"),
@@ -3764,9 +4165,12 @@ class QuestService:
3764
4165
  def bind_source(self, quest_id: str, source: str) -> dict:
3765
4166
  quest_root = self._quest_root(quest_id)
3766
4167
  bindings_path = quest_root / ".ds" / "bindings.json"
4168
+ existing_payload = read_json(bindings_path, {"sources": []})
4169
+ existing_sources = existing_payload.get("sources") if isinstance(existing_payload, dict) else []
3767
4170
  bindings = self._binding_sources_payload(quest_root)
3768
4171
  normalized_source = self._normalize_binding_source(source)
3769
- next_sources = self._normalized_binding_sources([*(bindings.get("sources") or []), normalized_source])
4172
+ seed_sources = list(existing_sources) if isinstance(existing_sources, list) else list(bindings.get("sources") or [])
4173
+ next_sources = self._normalized_binding_sources([*seed_sources, normalized_source])
3770
4174
  changed = list(bindings.get("sources") or []) != next_sources
3771
4175
  if changed:
3772
4176
  bindings["sources"] = next_sources
@@ -3824,6 +4228,8 @@ class QuestService:
3824
4228
  title: str | None = None,
3825
4229
  active_anchor: str | None = None,
3826
4230
  default_runner: str | None = None,
4231
+ workspace_mode: str | None = None,
4232
+ decision_policy: str | None = None,
3827
4233
  ) -> dict:
3828
4234
  quest_root = self._quest_root(quest_id)
3829
4235
  quest_yaml_path = self._quest_yaml_path(quest_root)
@@ -3832,6 +4238,8 @@ class QuestService:
3832
4238
 
3833
4239
  quest_data = self.read_quest_yaml(quest_root)
3834
4240
  changed = False
4241
+ research_state_updates: dict[str, Any] = {}
4242
+ runtime_state_updates: dict[str, Any] = {}
3835
4243
 
3836
4244
  if title is not None:
3837
4245
  normalized_title = str(title).strip()
@@ -3865,13 +4273,73 @@ class QuestService:
3865
4273
  if normalized_runner not in available_runners:
3866
4274
  allowed = ", ".join(sorted(available_runners))
3867
4275
  raise ValueError(f"Unsupported runner `{normalized_runner}`. Available runners: {allowed}.")
4276
+ runners = ConfigManager(self.home).load_runners_config()
4277
+ runner_cfg = runners.get(normalized_runner, {}) if isinstance(runners.get(normalized_runner), dict) else {}
4278
+ if runner_cfg.get("enabled") is False:
4279
+ fallback = self._resolve_enabled_runner_name(normalized_runner)
4280
+ if fallback == normalized_runner:
4281
+ raise ValueError(f"Runner `{normalized_runner}` is disabled and no enabled fallback runner is available.")
4282
+ normalized_runner = fallback
3868
4283
  if quest_data.get("default_runner") != normalized_runner:
3869
4284
  quest_data["default_runner"] = normalized_runner
3870
4285
  changed = True
3871
4286
 
4287
+ if workspace_mode is not None:
4288
+ normalized_workspace_mode = str(workspace_mode).strip().lower()
4289
+ if normalized_workspace_mode not in {"copilot", "autonomous"}:
4290
+ raise ValueError("Unsupported workspace mode. Allowed values: copilot, autonomous.")
4291
+ startup_contract = (
4292
+ dict(quest_data.get("startup_contract") or {})
4293
+ if isinstance(quest_data.get("startup_contract"), dict)
4294
+ else {}
4295
+ )
4296
+ if str(startup_contract.get("workspace_mode") or "").strip().lower() != normalized_workspace_mode:
4297
+ startup_contract["workspace_mode"] = normalized_workspace_mode
4298
+ quest_data["startup_contract"] = startup_contract
4299
+ changed = True
4300
+ if str(self.read_research_state(quest_root).get("workspace_mode") or "").strip().lower() != normalized_workspace_mode:
4301
+ research_state_updates["workspace_mode"] = normalized_workspace_mode
4302
+ runtime_state_updates["continuation_policy"] = (
4303
+ "wait_for_user_or_resume" if normalized_workspace_mode == "copilot" else "auto"
4304
+ )
4305
+ runtime_state_updates["continuation_reason"] = (
4306
+ "copilot_mode" if normalized_workspace_mode == "copilot" else "autonomous_mode"
4307
+ )
4308
+
4309
+ if decision_policy is not None:
4310
+ normalized_decision_policy = str(decision_policy).strip().lower()
4311
+ if normalized_decision_policy not in {"autonomous", "user_gated"}:
4312
+ raise ValueError("Unsupported decision policy. Allowed values: autonomous, user_gated.")
4313
+ startup_contract = (
4314
+ dict(quest_data.get("startup_contract") or {})
4315
+ if isinstance(quest_data.get("startup_contract"), dict)
4316
+ else {}
4317
+ )
4318
+ if str(startup_contract.get("decision_policy") or "").strip().lower() != normalized_decision_policy:
4319
+ startup_contract["decision_policy"] = normalized_decision_policy
4320
+ quest_data["startup_contract"] = startup_contract
4321
+ changed = True
4322
+ effective_workspace_mode = str(
4323
+ research_state_updates.get("workspace_mode")
4324
+ or self.read_research_state(quest_root).get("workspace_mode")
4325
+ or startup_contract.get("workspace_mode")
4326
+ or ""
4327
+ ).strip().lower()
4328
+ if normalized_decision_policy == "autonomous" and effective_workspace_mode == "autonomous":
4329
+ runtime_state = self._read_runtime_state(quest_root)
4330
+ current_policy = str(runtime_state.get("continuation_policy") or "").strip().lower()
4331
+ current_reason = str(runtime_state.get("continuation_reason") or "").strip()
4332
+ if current_policy == "wait_for_user_or_resume" and current_reason not in AUTONOMOUS_BLOCKING_WAIT_REASONS:
4333
+ runtime_state_updates["continuation_policy"] = "auto"
4334
+ runtime_state_updates["continuation_reason"] = "autonomous_decision_policy"
4335
+
3872
4336
  if changed:
3873
4337
  quest_data["updated_at"] = utc_now()
3874
4338
  write_yaml(quest_yaml_path, quest_data)
4339
+ if research_state_updates:
4340
+ self.update_research_state(quest_root, **research_state_updates)
4341
+ if runtime_state_updates:
4342
+ self.update_runtime_state(quest_root=quest_root, **runtime_state_updates)
3875
4343
 
3876
4344
  return self.snapshot(quest_id)
3877
4345
 
@@ -3973,11 +4441,26 @@ class QuestService:
3973
4441
  active_run_id=active_run_id or None,
3974
4442
  last_transition_at=last_transition_at,
3975
4443
  )
4444
+ # Reconcile continuation_policy with current workspace_mode so that
4445
+ # a mode switch that happened before/during the crash is respected.
4446
+ research_state = self.read_research_state(quest_root)
4447
+ workspace_mode = str(research_state.get("workspace_mode") or "").strip().lower()
4448
+ current_policy = str(runtime_state.get("continuation_policy") or "").strip().lower()
4449
+ reconciled_policy_updates: dict[str, Any] = {}
4450
+ if workspace_mode == "autonomous" and current_policy == "wait_for_user_or_resume":
4451
+ reconciled_policy_updates["continuation_policy"] = "auto"
4452
+ reconciled_policy_updates["continuation_reason"] = "autonomous_mode_reconciled"
4453
+ reconciled_policy_updates["continuation_updated_at"] = utc_now()
4454
+ elif workspace_mode == "copilot" and current_policy == "auto":
4455
+ reconciled_policy_updates["continuation_policy"] = "wait_for_user_or_resume"
4456
+ reconciled_policy_updates["continuation_reason"] = "copilot_mode_reconciled"
4457
+ reconciled_policy_updates["continuation_updated_at"] = utc_now()
3976
4458
  self.update_runtime_state(
3977
4459
  quest_root=quest_root,
3978
4460
  status="stopped",
3979
4461
  active_run_id=None,
3980
4462
  stop_reason="crash_recovered",
4463
+ **reconciled_policy_updates,
3981
4464
  )
3982
4465
  summary = (
3983
4466
  f"Recovered quest from stale runtime state; previous status `{previous_status}`"
@@ -4117,8 +4600,15 @@ class QuestService:
4117
4600
 
4118
4601
  def node_traces(self, quest_id: str, *, selection_type: str | None = None) -> dict:
4119
4602
  quest_root = self._quest_root(quest_id)
4120
- workflow = self.workflow(quest_id)
4121
4603
  snapshot = self.snapshot(quest_id)
4604
+ try:
4605
+ workflow = self._build_details_projection_payload(
4606
+ quest_root,
4607
+ source_signature=self._projection_source_signature(quest_root, "details"),
4608
+ update_progress=lambda *_args, **_kwargs: None,
4609
+ )
4610
+ except Exception:
4611
+ workflow = self.workflow(quest_id)
4122
4612
  payload = QuestNodeTraceManager(quest_root).materialize(
4123
4613
  quest_id=quest_id,
4124
4614
  workflow=workflow,
@@ -4308,7 +4798,7 @@ class QuestService:
4308
4798
  return payload
4309
4799
 
4310
4800
  def list_documents(self, quest_id: str) -> list[dict]:
4311
- quest_root = self._quest_root(quest_id)
4801
+ quest_root = self._require_initialized_quest_root(quest_id)
4312
4802
  workspace_root = self.active_workspace_root(quest_root)
4313
4803
  documents = []
4314
4804
  for relative in ("brief.md", "plan.md", "status.md", "SUMMARY.md"):
@@ -4359,10 +4849,11 @@ class QuestService:
4359
4849
  mode: str | None = None,
4360
4850
  profile: str | None = None,
4361
4851
  ) -> dict:
4852
+ profile = str(profile or "").strip().lower() or None
4362
4853
  if revision:
4363
4854
  return self._revision_explorer(quest_id, revision=revision, mode=mode or "ref")
4364
4855
 
4365
- quest_root = self._quest_root(quest_id)
4856
+ quest_root = self._require_initialized_quest_root(quest_id)
4366
4857
  workspace_root = self.active_workspace_root(quest_root)
4367
4858
  git_status = self._git_status_map(workspace_root)
4368
4859
 
@@ -4389,9 +4880,9 @@ class QuestService:
4389
4880
  }
4390
4881
 
4391
4882
  def search_files(self, quest_id: str, term: str, limit: int = 50) -> dict[str, Any]:
4392
- query = term.strip()
4883
+ query = self._normalize_explorer_search_query(term)
4393
4884
  normalized_query = query.casefold()
4394
- workspace_root = self.active_workspace_root(self._quest_root(quest_id))
4885
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
4395
4886
  resolved_limit = max(1, min(limit, 200))
4396
4887
  if not normalized_query:
4397
4888
  return {
@@ -4415,6 +4906,41 @@ class QuestService:
4415
4906
  except OSError:
4416
4907
  continue
4417
4908
 
4909
+ relative = path.relative_to(workspace_root).as_posix()
4910
+ scope, writable = self._classify_path_scope(workspace_root, path)
4911
+ path_haystack = relative.casefold()
4912
+ name_haystack = path.name.casefold()
4913
+ if normalized_query in path_haystack or normalized_query in name_haystack:
4914
+ haystack = path_haystack
4915
+ match_spans: list[dict[str, int]] = []
4916
+ start = 0
4917
+ while True:
4918
+ found = haystack.find(normalized_query, start)
4919
+ if found < 0:
4920
+ break
4921
+ match_spans.append({"start": found, "end": found + len(query)})
4922
+ start = found + max(1, len(query))
4923
+ renderer_hint, mime_type = self._renderer_hint_for(path)
4924
+ items.append(
4925
+ {
4926
+ "id": f"{relative}:path",
4927
+ "document_id": f"path::{relative}",
4928
+ "title": path.name,
4929
+ "path": relative,
4930
+ "scope": scope,
4931
+ "writable": writable,
4932
+ "line_number": 0,
4933
+ "line_text": relative,
4934
+ "snippet": relative[:320],
4935
+ "match_spans": match_spans,
4936
+ "open_kind": renderer_hint,
4937
+ "mime_type": mime_type,
4938
+ }
4939
+ )
4940
+ if len(items) >= resolved_limit:
4941
+ truncated = True
4942
+ break
4943
+
4418
4944
  renderer_hint, mime_type = self._renderer_hint_for(path)
4419
4945
  if not self._is_text_document(path, mime_type, renderer_hint):
4420
4946
  continue
@@ -4437,8 +4963,6 @@ class QuestService:
4437
4963
  continue
4438
4964
 
4439
4965
  files_scanned += 1
4440
- relative = path.relative_to(workspace_root).as_posix()
4441
- scope, writable = self._classify_path_scope(workspace_root, path)
4442
4966
 
4443
4967
  for line_index, line in enumerate(content.splitlines(), start=1):
4444
4968
  haystack = line.casefold()
@@ -4485,8 +5009,17 @@ class QuestService:
4485
5009
  "files_scanned": files_scanned,
4486
5010
  }
4487
5011
 
5012
+ @staticmethod
5013
+ def _normalize_explorer_search_query(term: str) -> str:
5014
+ query = str(term or "").strip()
5015
+ if len(query) >= 2 and query.startswith("*") and query.endswith("*"):
5016
+ inner = query.strip("*").strip()
5017
+ if inner and not any(marker in inner for marker in "*?[]"):
5018
+ return inner
5019
+ return query
5020
+
4488
5021
  def open_document(self, quest_id: str, document_id: str) -> dict:
4489
- quest_root = self._quest_root(quest_id)
5022
+ quest_root = self._require_initialized_quest_root(quest_id)
4490
5023
  workspace_root = self.active_workspace_root(quest_root)
4491
5024
  if document_id.startswith("git::"):
4492
5025
  revision, relative = self._parse_git_document_id(document_id)
@@ -4522,12 +5055,16 @@ class QuestService:
4522
5055
  },
4523
5056
  }
4524
5057
 
5058
+ shared_source_quest_id = None
5059
+ parsed_shared_memory = self._parse_shared_memory_document_id(document_id)
5060
+ if parsed_shared_memory is not None:
5061
+ shared_source_quest_id = parsed_shared_memory[0]
4525
5062
  path, writable, scope, source_kind = self.resolve_document(quest_id, document_id)
4526
5063
  renderer_hint, mime_type = self._renderer_hint_for(path)
4527
5064
  is_text = self._is_text_document(path, mime_type, renderer_hint)
4528
5065
  content = read_text(path) if is_text else ""
4529
5066
  revision = f"sha256:{sha256_text(content)}" if is_text else f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}"
4530
- return {
5067
+ payload = {
4531
5068
  "document_id": document_id,
4532
5069
  "quest_id": quest_id,
4533
5070
  "title": path.name if "::" in document_id else document_id,
@@ -4549,9 +5086,17 @@ class QuestService:
4549
5086
  "renderer_hint": renderer_hint,
4550
5087
  },
4551
5088
  }
5089
+ if shared_source_quest_id:
5090
+ payload["source_quest_id"] = shared_source_quest_id
5091
+ if isinstance(payload.get("meta"), dict):
5092
+ payload["meta"]["source_quest_id"] = shared_source_quest_id
5093
+ payload["meta"]["shared"] = True
5094
+ return payload
4552
5095
 
4553
5096
  def resolve_document(self, quest_id: str, document_id: str) -> tuple[Path, bool, str, str]:
4554
- quest_root = self._quest_root(quest_id)
5097
+ quest_root = self._require_initialized_quest_root(quest_id)
5098
+ if document_id.startswith(_SHARED_MEMORY_DOCUMENT_PREFIX):
5099
+ return self._resolve_shared_memory_document(document_id)
4555
5100
  workspace_root = self.active_workspace_root(quest_root)
4556
5101
  resolution_root = self._document_resolution_root(
4557
5102
  quest_root=quest_root,
@@ -4568,6 +5113,32 @@ class QuestService:
4568
5113
  return self._resolve_document(quest_root, f"questpath::{legacy_relative}")
4569
5114
  raise
4570
5115
 
5116
+ def _resolve_shared_memory_document(self, document_id: str) -> tuple[Path, bool, str, str]:
5117
+ parsed = self._parse_shared_memory_document_id(document_id)
5118
+ if parsed is None:
5119
+ raise FileNotFoundError(f"Unknown shared memory document `{document_id}`.")
5120
+ source_quest_id, relative = parsed
5121
+ source_quest_root = self._require_initialized_quest_root(source_quest_id)
5122
+ root = (source_quest_root / "memory").resolve()
5123
+ path = (root / relative).resolve()
5124
+ if path != root and root not in path.parents:
5125
+ raise ValueError("Document ID escapes shared quest memory.")
5126
+ if not path.exists() or not path.is_file():
5127
+ raise FileNotFoundError(f"Unknown shared quest memory `{source_quest_id}:{relative}`.")
5128
+ return path, False, "shared_quest_memory", "shared_quest_memory"
5129
+
5130
+ @staticmethod
5131
+ def _parse_shared_memory_document_id(document_id: str) -> tuple[str, str] | None:
5132
+ raw = str(document_id or "").strip()
5133
+ if not raw.startswith(_SHARED_MEMORY_DOCUMENT_PREFIX):
5134
+ return None
5135
+ _prefix, source_quest_id, relative = (raw.split("::", 2) + ["", "", ""])[:3]
5136
+ source_quest_id = source_quest_id.strip()
5137
+ relative = relative.lstrip("/")
5138
+ if not source_quest_id or not relative:
5139
+ return None
5140
+ return source_quest_id, relative
5141
+
4571
5142
  def save_document(self, quest_id: str, document_id: str, content: str, previous_revision: str | None = None) -> dict:
4572
5143
  current = self.open_document(quest_id, document_id)
4573
5144
  if not current.get("writable", False):
@@ -4744,20 +5315,545 @@ class QuestService:
4744
5315
  "saved_at": utc_now(),
4745
5316
  }
4746
5317
 
4747
- def _revision_explorer(self, quest_id: str, *, revision: str, mode: str) -> dict:
5318
+ def save_chat_attachment_draft(
5319
+ self,
5320
+ quest_id: str,
5321
+ *,
5322
+ file_name: str,
5323
+ mime_type: str | None,
5324
+ content: bytes,
5325
+ draft_id: str | None = None,
5326
+ ) -> dict[str, Any]:
4748
5327
  quest_root = self._quest_root(quest_id)
4749
- if not self._git_revision_exists(quest_root, revision):
4750
- raise FileNotFoundError(f"Unknown git revision `{revision}`.")
4751
-
4752
- snapshot_paths = self._git_snapshot_paths(quest_root, revision)
4753
- snapshot_tree = self._build_snapshot_tree(snapshot_paths)
4754
- root_nodes = self._snapshot_children(snapshot_tree, revision=revision, prefix="")
4755
- sections = self._group_explorer_sections(root_nodes)
4756
-
5328
+ normalized_draft_id = slugify(str(draft_id or generate_id("draft")), default=generate_id("draft"))
5329
+ draft_root = self._chat_attachment_draft_root(quest_root, normalized_draft_id)
5330
+ original_name = Path(file_name).name or "attachment.bin"
5331
+ suffix = Path(original_name).suffix.lower()
5332
+ if not suffix:
5333
+ guessed_suffix = mimetypes.guess_extension(mime_type or "", strict=False) or ""
5334
+ suffix = ".jpg" if guessed_suffix == ".jpe" else guessed_suffix
5335
+ safe_stem = slugify(Path(original_name).stem or "attachment", default="attachment")
5336
+ stored_name = f"{safe_stem}{suffix or '.bin'}"
5337
+ asset_path = resolve_within(draft_root, stored_name)
5338
+ if draft_root.exists():
5339
+ for child in draft_root.iterdir():
5340
+ if child.is_file():
5341
+ child.unlink(missing_ok=True)
5342
+ elif child.is_dir():
5343
+ shutil.rmtree(child, ignore_errors=True)
5344
+ ensure_dir(draft_root)
5345
+ asset_path.write_bytes(content)
5346
+ quest_relative_path = asset_path.relative_to(quest_root).as_posix()
5347
+ asset_document_id = f"path::{quest_relative_path}"
5348
+ attachment = self._chat_attachment_payload(
5349
+ quest_id=quest_id,
5350
+ name=original_name,
5351
+ stored_name=stored_name,
5352
+ mime_type=mime_type,
5353
+ asset_path=asset_path,
5354
+ draft_id=normalized_draft_id,
5355
+ )
5356
+ attachment["status"] = "success"
5357
+ write_json(
5358
+ draft_root / "manifest.json",
5359
+ {
5360
+ "draft_id": normalized_draft_id,
5361
+ "quest_id": quest_id,
5362
+ "created_at": utc_now(),
5363
+ "attachment": attachment,
5364
+ "asset_document_id": asset_document_id,
5365
+ },
5366
+ )
4757
5367
  return {
5368
+ "ok": True,
4758
5369
  "quest_id": quest_id,
4759
- "quest_root": str(quest_root.resolve()),
4760
- "view": {
5370
+ "draft_id": normalized_draft_id,
5371
+ **attachment,
5372
+ }
5373
+
5374
+ def delete_chat_attachment_draft(self, quest_id: str, *, draft_id: str) -> dict[str, Any]:
5375
+ self._quest_root(quest_id)
5376
+ normalized_draft_id = slugify(str(draft_id or "").strip(), default="")
5377
+ if not normalized_draft_id:
5378
+ return {"ok": False, "message": "`draft_id` is required."}
5379
+ draft_root = self._chat_attachment_draft_root(self._quest_root(quest_id), normalized_draft_id)
5380
+ if not draft_root.exists():
5381
+ return {
5382
+ "ok": True,
5383
+ "status": "already_deleted",
5384
+ "quest_id": quest_id,
5385
+ "draft_id": normalized_draft_id,
5386
+ }
5387
+ shutil.rmtree(draft_root, ignore_errors=True)
5388
+ return {
5389
+ "ok": True,
5390
+ "status": "deleted",
5391
+ "quest_id": quest_id,
5392
+ "draft_id": normalized_draft_id,
5393
+ }
5394
+
5395
+ def import_chat_attachment_drafts(
5396
+ self,
5397
+ target_quest_id: str,
5398
+ *,
5399
+ source_quest_id: str,
5400
+ attachments: list[dict[str, Any]],
5401
+ ) -> dict[str, Any]:
5402
+ target_quest_root = self._quest_root(target_quest_id)
5403
+ source_quest_root = self._quest_root(source_quest_id)
5404
+ imported: list[dict[str, Any]] = []
5405
+ for index, raw_attachment in enumerate(attachments, start=1):
5406
+ if not isinstance(raw_attachment, dict):
5407
+ continue
5408
+ quest_relative_path = str(raw_attachment.get("quest_relative_path") or "").strip()
5409
+ absolute_path = str(raw_attachment.get("path") or "").strip()
5410
+ source_path: Path | None = None
5411
+ if quest_relative_path:
5412
+ source_path = resolve_within(source_quest_root, quest_relative_path)
5413
+ elif absolute_path:
5414
+ candidate = Path(absolute_path).resolve()
5415
+ if candidate == source_quest_root or source_quest_root in candidate.parents:
5416
+ source_path = candidate
5417
+ if source_path is None or not source_path.exists() or not source_path.is_file():
5418
+ continue
5419
+ file_name = str(
5420
+ raw_attachment.get("name")
5421
+ or raw_attachment.get("file_name")
5422
+ or source_path.name
5423
+ or f"attachment-{index:03d}"
5424
+ ).strip() or f"attachment-{index:03d}"
5425
+ mime_type = str(raw_attachment.get("content_type") or raw_attachment.get("mime_type") or "").strip() or None
5426
+ created = self.save_chat_attachment_draft(
5427
+ target_quest_root.name,
5428
+ file_name=file_name,
5429
+ mime_type=mime_type,
5430
+ content=source_path.read_bytes(),
5431
+ )
5432
+ imported.append(created)
5433
+ return {
5434
+ "ok": True,
5435
+ "quest_id": target_quest_id,
5436
+ "source_quest_id": source_quest_id,
5437
+ "imported_count": len(imported),
5438
+ "attachments": imported,
5439
+ }
5440
+
5441
+ def finalize_chat_attachment_drafts(
5442
+ self,
5443
+ quest_id: str,
5444
+ *,
5445
+ draft_ids: list[str],
5446
+ client_message_id: str | None,
5447
+ ) -> list[dict[str, Any]]:
5448
+ quest_root = self._quest_root(quest_id)
5449
+ normalized_draft_ids = [
5450
+ slugify(str(item or "").strip(), default="")
5451
+ for item in draft_ids
5452
+ if str(item or "").strip()
5453
+ ]
5454
+ if not normalized_draft_ids:
5455
+ return []
5456
+ batch_slug = slugify(
5457
+ str(client_message_id or generate_id("userfile")).strip(),
5458
+ default=generate_id("userfile"),
5459
+ )
5460
+ batch_root = ensure_dir(quest_root / "userfiles" / "web" / batch_slug)
5461
+ materialized: list[dict[str, Any]] = []
5462
+ for index, normalized_draft_id in enumerate(normalized_draft_ids, start=1):
5463
+ draft_root = self._chat_attachment_draft_root(quest_root, normalized_draft_id)
5464
+ manifest = read_json(draft_root / "manifest.json", {})
5465
+ attachment = dict(manifest.get("attachment") or {})
5466
+ source_path = Path(str(attachment.get("path") or "").strip())
5467
+ if not draft_root.exists() or not source_path.exists():
5468
+ raise FileNotFoundError(f"Unknown chat attachment draft `{normalized_draft_id}`.")
5469
+ target_name = self._dedupe_attachment_filename(
5470
+ batch_root,
5471
+ str(attachment.get("stored_name") or source_path.name or f"attachment-{index:03d}.bin"),
5472
+ )
5473
+ target_path = resolve_within(batch_root, target_name)
5474
+ ensure_dir(target_path.parent)
5475
+ shutil.move(str(source_path), str(target_path))
5476
+ finalized = self._chat_attachment_payload(
5477
+ quest_id=quest_id,
5478
+ name=str(attachment.get("name") or target_name),
5479
+ stored_name=target_name,
5480
+ mime_type=str(attachment.get("content_type") or "").strip() or None,
5481
+ asset_path=target_path,
5482
+ draft_id=None,
5483
+ )
5484
+ finalized["source_path"] = str(source_path)
5485
+ finalized["materialized"] = True
5486
+ finalized["uploaded_at"] = str(attachment.get("uploaded_at") or utc_now())
5487
+ materialized.append(finalized)
5488
+ shutil.rmtree(draft_root, ignore_errors=True)
5489
+ write_json(
5490
+ batch_root / "manifest.json",
5491
+ {
5492
+ "quest_id": quest_id,
5493
+ "client_message_id": str(client_message_id or "").strip() or None,
5494
+ "materialized_at": utc_now(),
5495
+ "attachments": materialized,
5496
+ },
5497
+ )
5498
+ return materialized
5499
+
5500
+ @staticmethod
5501
+ def _chat_attachment_draft_root(quest_root: Path, draft_id: str) -> Path:
5502
+ return quest_root / "userfiles" / "web" / "_staging" / draft_id
5503
+
5504
+ @staticmethod
5505
+ def _dedupe_attachment_filename(batch_root: Path, file_name: str) -> str:
5506
+ base_name = Path(file_name).name or "attachment.bin"
5507
+ stem = Path(base_name).stem or "attachment"
5508
+ suffix = Path(base_name).suffix
5509
+ candidate = base_name
5510
+ counter = 2
5511
+ while (batch_root / candidate).exists():
5512
+ candidate = f"{stem}-{counter}{suffix}"
5513
+ counter += 1
5514
+ return candidate
5515
+
5516
+ @staticmethod
5517
+ def _is_readable_chat_attachment(path: Path, mime_type: str | None) -> bool:
5518
+ normalized_mime = str(mime_type or "").strip().lower()
5519
+ if any(normalized_mime.startswith(prefix) for prefix in _CHAT_ATTACHMENT_TEXT_MIME_PREFIXES):
5520
+ return True
5521
+ if normalized_mime in _CHAT_ATTACHMENT_TEXT_MIME_TYPES:
5522
+ return True
5523
+ return path.suffix.lower() in _CHAT_ATTACHMENT_TEXT_EXTENSIONS
5524
+
5525
+ def _chat_attachment_payload(
5526
+ self,
5527
+ *,
5528
+ quest_id: str,
5529
+ name: str,
5530
+ stored_name: str,
5531
+ mime_type: str | None,
5532
+ asset_path: Path,
5533
+ draft_id: str | None,
5534
+ ) -> dict[str, Any]:
5535
+ quest_root = self._quest_root(quest_id)
5536
+ resolved_path = asset_path.resolve()
5537
+ content_type = mimetypes.guess_type(resolved_path.name)[0] or mime_type or "application/octet-stream"
5538
+ quest_relative_path = resolved_path.relative_to(quest_root).as_posix()
5539
+ payload: dict[str, Any] = {
5540
+ "kind": "image" if str(content_type).startswith("image/") else "path",
5541
+ "name": name,
5542
+ "file_name": stored_name,
5543
+ "content_type": content_type,
5544
+ "path": str(resolved_path),
5545
+ "quest_relative_path": quest_relative_path,
5546
+ "asset_document_id": f"path::{quest_relative_path}",
5547
+ "asset_url": f"/api/quests/{quest_id}/documents/asset?document_id={quote(f'path::{quest_relative_path}', safe='')}",
5548
+ "size_bytes": resolved_path.stat().st_size if resolved_path.exists() else 0,
5549
+ "uploaded_at": utc_now(),
5550
+ "upload_origin": "web",
5551
+ }
5552
+ if draft_id:
5553
+ payload["draft_id"] = draft_id
5554
+ if self._is_readable_chat_attachment(resolved_path, content_type):
5555
+ payload["extracted_text_path"] = quest_relative_path
5556
+ return payload
5557
+
5558
+ @staticmethod
5559
+ def _normalize_workspace_relative_path(
5560
+ relative: str | None,
5561
+ *,
5562
+ field_name: str,
5563
+ allow_root: bool = True,
5564
+ ) -> str | None:
5565
+ if relative is None:
5566
+ if allow_root:
5567
+ return None
5568
+ raise ValueError(f"`{field_name}` is required.")
5569
+ raw = str(relative).strip().replace("\\", "/")
5570
+ if not raw:
5571
+ if allow_root:
5572
+ return None
5573
+ raise ValueError(f"`{field_name}` is required.")
5574
+ normalized = raw.lstrip("/").rstrip("/")
5575
+ if normalized in {"", "."}:
5576
+ if allow_root:
5577
+ return None
5578
+ raise ValueError(f"`{field_name}` must point to a workspace entry.")
5579
+ return normalized
5580
+
5581
+ @staticmethod
5582
+ def _normalize_workspace_entry_name(name: str | None, *, field_name: str) -> str:
5583
+ raw = str(name or "").strip().replace("\\", "/")
5584
+ if not raw:
5585
+ raise ValueError(f"`{field_name}` is required.")
5586
+ if "/" in raw:
5587
+ raise ValueError(f"`{field_name}` must be a single path segment.")
5588
+ candidate = Path(raw).name
5589
+ if candidate != raw or candidate in {"", ".", ".."}:
5590
+ raise ValueError(f"`{field_name}` must be a valid file or folder name.")
5591
+ if candidate == ".git":
5592
+ raise ValueError("`.git` cannot be created or renamed from the explorer.")
5593
+ return candidate
5594
+
5595
+ @staticmethod
5596
+ def _normalize_workspace_path_list(paths: Any, *, field_name: str) -> list[str]:
5597
+ if not isinstance(paths, list) or not paths:
5598
+ raise ValueError(f"`{field_name}` must be a non-empty list.")
5599
+ normalized: list[str] = []
5600
+ seen: set[str] = set()
5601
+ for raw in paths:
5602
+ item = QuestService._normalize_workspace_relative_path(
5603
+ raw,
5604
+ field_name=field_name,
5605
+ allow_root=False,
5606
+ )
5607
+ if not item or item in seen:
5608
+ continue
5609
+ seen.add(item)
5610
+ normalized.append(item)
5611
+ if not normalized:
5612
+ raise ValueError(f"`{field_name}` must include at least one valid path.")
5613
+ return normalized
5614
+
5615
+ @staticmethod
5616
+ def _filter_nested_workspace_paths(paths: list[str]) -> list[str]:
5617
+ kept: list[str] = []
5618
+ for path in paths:
5619
+ if any(path == parent or path.startswith(f"{parent}/") for parent in kept):
5620
+ continue
5621
+ kept.append(path)
5622
+ return kept
5623
+
5624
+ def _workspace_entry_payload(self, workspace_root: Path, path: Path) -> dict:
5625
+ if path.is_dir():
5626
+ return self._directory_node(
5627
+ workspace_root,
5628
+ path=path,
5629
+ children=[],
5630
+ git_status={},
5631
+ changed_paths={},
5632
+ )
5633
+ payload = self._file_node(
5634
+ workspace_root,
5635
+ path=path,
5636
+ git_status={},
5637
+ changed_paths={},
5638
+ )
5639
+ if payload is None:
5640
+ raise FileNotFoundError(f"Unknown workspace entry `{path}`.")
5641
+ return payload
5642
+
5643
+ def create_workspace_folder(
5644
+ self,
5645
+ quest_id: str,
5646
+ *,
5647
+ name: str | None,
5648
+ parent_path: str | None = None,
5649
+ ) -> dict:
5650
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
5651
+ normalized_parent = self._normalize_workspace_relative_path(
5652
+ parent_path,
5653
+ field_name="parent_path",
5654
+ allow_root=True,
5655
+ )
5656
+ folder_name = self._normalize_workspace_entry_name(name, field_name="name")
5657
+ parent = resolve_within(workspace_root, normalized_parent) if normalized_parent else workspace_root
5658
+ if not parent.exists() or not parent.is_dir():
5659
+ raise FileNotFoundError(
5660
+ f"Unknown destination folder `{normalized_parent or '.'}`."
5661
+ )
5662
+ target = resolve_within(parent, folder_name)
5663
+ if target.exists():
5664
+ raise FileExistsError(
5665
+ f"`{target.relative_to(workspace_root).as_posix()}` already exists."
5666
+ )
5667
+ ensure_dir(target)
5668
+ return {
5669
+ "ok": True,
5670
+ "quest_id": quest_id,
5671
+ "parent_path": normalized_parent,
5672
+ "item": self._workspace_entry_payload(workspace_root, target),
5673
+ "saved_at": utc_now(),
5674
+ }
5675
+
5676
+ def upload_workspace_file(
5677
+ self,
5678
+ quest_id: str,
5679
+ *,
5680
+ file_name: str | None,
5681
+ content: bytes,
5682
+ mime_type: str | None = None,
5683
+ parent_path: str | None = None,
5684
+ ) -> dict:
5685
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
5686
+ normalized_parent = self._normalize_workspace_relative_path(
5687
+ parent_path,
5688
+ field_name="parent_path",
5689
+ allow_root=True,
5690
+ )
5691
+ safe_name = self._normalize_workspace_entry_name(file_name, field_name="file_name")
5692
+ parent = resolve_within(workspace_root, normalized_parent) if normalized_parent else workspace_root
5693
+ if not parent.exists() or not parent.is_dir():
5694
+ raise FileNotFoundError(
5695
+ f"Unknown destination folder `{normalized_parent or '.'}`."
5696
+ )
5697
+ target = resolve_within(parent, safe_name)
5698
+ if target.exists():
5699
+ raise FileExistsError(
5700
+ f"`{target.relative_to(workspace_root).as_posix()}` already exists."
5701
+ )
5702
+ ensure_dir(target.parent)
5703
+ target.write_bytes(content)
5704
+ payload = self._workspace_entry_payload(workspace_root, target)
5705
+ guessed_mime = mimetypes.guess_type(target.name)[0] or mime_type or "application/octet-stream"
5706
+ payload["mime_type"] = guessed_mime
5707
+ return {
5708
+ "ok": True,
5709
+ "quest_id": quest_id,
5710
+ "parent_path": normalized_parent,
5711
+ "item": payload,
5712
+ "saved_at": utc_now(),
5713
+ }
5714
+
5715
+ def rename_workspace_entry(
5716
+ self,
5717
+ quest_id: str,
5718
+ *,
5719
+ path: str | None,
5720
+ new_name: str | None,
5721
+ ) -> dict:
5722
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
5723
+ normalized_path = self._normalize_workspace_relative_path(
5724
+ path,
5725
+ field_name="path",
5726
+ allow_root=False,
5727
+ )
5728
+ source = resolve_within(workspace_root, normalized_path)
5729
+ if not source.exists():
5730
+ raise FileNotFoundError(f"Unknown workspace entry `{normalized_path}`.")
5731
+ safe_name = self._normalize_workspace_entry_name(new_name, field_name="new_name")
5732
+ target = resolve_within(source.parent, safe_name)
5733
+ if target.exists() and target != source:
5734
+ raise FileExistsError(
5735
+ f"`{target.relative_to(workspace_root).as_posix()}` already exists."
5736
+ )
5737
+ if target != source:
5738
+ source.rename(target)
5739
+ payload = self._workspace_entry_payload(workspace_root, target)
5740
+ return {
5741
+ "ok": True,
5742
+ "quest_id": quest_id,
5743
+ "previous_path": normalized_path,
5744
+ "item": payload,
5745
+ "saved_at": utc_now(),
5746
+ }
5747
+
5748
+ def move_workspace_entries(
5749
+ self,
5750
+ quest_id: str,
5751
+ *,
5752
+ paths: Any,
5753
+ target_parent_path: str | None = None,
5754
+ ) -> dict:
5755
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
5756
+ normalized_paths = self._filter_nested_workspace_paths(
5757
+ self._normalize_workspace_path_list(paths, field_name="paths")
5758
+ )
5759
+ normalized_target_parent = self._normalize_workspace_relative_path(
5760
+ target_parent_path,
5761
+ field_name="target_parent_path",
5762
+ allow_root=True,
5763
+ )
5764
+ target_parent = (
5765
+ resolve_within(workspace_root, normalized_target_parent)
5766
+ if normalized_target_parent
5767
+ else workspace_root
5768
+ )
5769
+ if not target_parent.exists() or not target_parent.is_dir():
5770
+ raise FileNotFoundError(
5771
+ f"Unknown destination folder `{normalized_target_parent or '.'}`."
5772
+ )
5773
+
5774
+ moves: list[tuple[str, Path, Path]] = []
5775
+ destination_keys: set[str] = set()
5776
+ target_parent_resolved = target_parent.resolve()
5777
+ for normalized_path in normalized_paths:
5778
+ source = resolve_within(workspace_root, normalized_path)
5779
+ if not source.exists():
5780
+ raise FileNotFoundError(f"Unknown workspace entry `{normalized_path}`.")
5781
+ source_resolved = source.resolve()
5782
+ if source_resolved == target_parent_resolved or source_resolved in target_parent_resolved.parents:
5783
+ raise ValueError(
5784
+ f"`{normalized_path}` cannot be moved into itself or one of its descendants."
5785
+ )
5786
+ destination = resolve_within(target_parent, source.name)
5787
+ if destination.exists() and destination.resolve() != source_resolved:
5788
+ raise FileExistsError(
5789
+ f"`{destination.relative_to(workspace_root).as_posix()}` already exists."
5790
+ )
5791
+ destination_key = str(destination.resolve())
5792
+ if destination_key in destination_keys and destination != source:
5793
+ raise FileExistsError(
5794
+ f"`{destination.relative_to(workspace_root).as_posix()}` would conflict with another moved entry."
5795
+ )
5796
+ destination_keys.add(destination_key)
5797
+ moves.append((normalized_path, source, destination))
5798
+
5799
+ items: list[dict] = []
5800
+ for _normalized_path, source, destination in moves:
5801
+ if destination != source:
5802
+ source.rename(destination)
5803
+ items.append(self._workspace_entry_payload(workspace_root, destination))
5804
+ return {
5805
+ "ok": True,
5806
+ "quest_id": quest_id,
5807
+ "target_parent_path": normalized_target_parent,
5808
+ "items": items,
5809
+ "saved_at": utc_now(),
5810
+ }
5811
+
5812
+ def delete_workspace_entries(
5813
+ self,
5814
+ quest_id: str,
5815
+ *,
5816
+ paths: Any,
5817
+ ) -> dict:
5818
+ workspace_root = self.active_workspace_root(self._require_initialized_quest_root(quest_id))
5819
+ normalized_paths = self._filter_nested_workspace_paths(
5820
+ self._normalize_workspace_path_list(paths, field_name="paths")
5821
+ )
5822
+ sources: list[Path] = []
5823
+ items: list[dict] = []
5824
+ for normalized_path in normalized_paths:
5825
+ source = resolve_within(workspace_root, normalized_path)
5826
+ if not source.exists():
5827
+ raise FileNotFoundError(f"Unknown workspace entry `{normalized_path}`.")
5828
+ sources.append(source)
5829
+ items.append(self._workspace_entry_payload(workspace_root, source))
5830
+
5831
+ for source in sorted(sources, key=lambda item: len(item.parts), reverse=True):
5832
+ if source.is_dir():
5833
+ shutil.rmtree(source)
5834
+ else:
5835
+ source.unlink()
5836
+ return {
5837
+ "ok": True,
5838
+ "quest_id": quest_id,
5839
+ "items": items,
5840
+ "saved_at": utc_now(),
5841
+ }
5842
+
5843
+ def _revision_explorer(self, quest_id: str, *, revision: str, mode: str) -> dict:
5844
+ quest_root = self._quest_root(quest_id)
5845
+ if not self._git_revision_exists(quest_root, revision):
5846
+ raise FileNotFoundError(f"Unknown git revision `{revision}`.")
5847
+
5848
+ snapshot_paths = self._git_snapshot_paths(quest_root, revision)
5849
+ snapshot_tree = self._build_snapshot_tree(snapshot_paths)
5850
+ root_nodes = self._snapshot_children(snapshot_tree, revision=revision, prefix="")
5851
+ sections = self._group_explorer_sections(root_nodes)
5852
+
5853
+ return {
5854
+ "quest_id": quest_id,
5855
+ "quest_root": str(quest_root.resolve()),
5856
+ "view": {
4761
5857
  "mode": mode,
4762
5858
  "revision": revision,
4763
5859
  "label": revision,
@@ -4874,9 +5970,10 @@ class QuestService:
4874
5970
  @staticmethod
4875
5971
  def _default_message_queue() -> dict[str, Any]:
4876
5972
  return {
4877
- "version": 1,
5973
+ "version": 2,
4878
5974
  "pending": [],
4879
5975
  "completed": [],
5976
+ "message_states": {},
4880
5977
  }
4881
5978
 
4882
5979
  def _default_runtime_state(
@@ -4907,6 +6004,7 @@ class QuestService:
4907
6004
  "continuation_anchor": None,
4908
6005
  "continuation_reason": None,
4909
6006
  "continuation_updated_at": None,
6007
+ "waiting_notice": None,
4910
6008
  "last_resume_source": None,
4911
6009
  "last_resume_at": None,
4912
6010
  "last_recovery_abandoned_run_id": None,
@@ -4918,6 +6016,7 @@ class QuestService:
4918
6016
  "last_delivered_batch_id": None,
4919
6017
  "last_delivered_at": None,
4920
6018
  "retry_state": None,
6019
+ "turn_message_override": None,
4921
6020
  }
4922
6021
 
4923
6022
  def _default_agent_status(self, quest_root: Path) -> dict[str, Any]:
@@ -4936,6 +6035,8 @@ class QuestService:
4936
6035
  }
4937
6036
 
4938
6037
  def _initialize_runtime_files(self, quest_root: Path) -> None:
6038
+ if not self._quest_yaml_path(quest_root).exists():
6039
+ raise FileNotFoundError(f"Unknown quest `{quest_root.name}`.")
4939
6040
  queue_path = self._message_queue_path(quest_root)
4940
6041
  if not queue_path.exists():
4941
6042
  write_json(queue_path, self._default_message_queue())
@@ -4956,9 +6057,11 @@ class QuestService:
4956
6057
  payload = self._read_cached_json(self._message_queue_path(quest_root), self._default_message_queue())
4957
6058
  if not isinstance(payload, dict):
4958
6059
  payload = self._default_message_queue()
4959
- payload.setdefault("version", 1)
6060
+ payload.setdefault("version", 2)
4960
6061
  payload.setdefault("pending", [])
4961
6062
  payload.setdefault("completed", [])
6063
+ message_states = payload.get("message_states")
6064
+ payload["message_states"] = dict(message_states) if isinstance(message_states, dict) else {}
4962
6065
  return payload
4963
6066
 
4964
6067
  def _write_message_queue(self, quest_root: Path, payload: dict[str, Any]) -> None:
@@ -4986,6 +6089,7 @@ class QuestService:
4986
6089
  merged["continuation_anchor"] = str(merged.get("continuation_anchor") or "").strip() or None
4987
6090
  merged["continuation_reason"] = str(merged.get("continuation_reason") or "").strip() or None
4988
6091
  merged["continuation_updated_at"] = str(merged.get("continuation_updated_at") or "").strip() or None
6092
+ merged["waiting_notice"] = dict(merged.get("waiting_notice") or {}) if isinstance(merged.get("waiting_notice"), dict) else None
4989
6093
  merged["last_resume_source"] = str(merged.get("last_resume_source") or "").strip() or None
4990
6094
  merged["last_resume_at"] = str(merged.get("last_resume_at") or "").strip() or None
4991
6095
  merged["last_recovery_abandoned_run_id"] = str(merged.get("last_recovery_abandoned_run_id") or "").strip() or None
@@ -4994,6 +6098,11 @@ class QuestService:
4994
6098
  merged["last_stage_fingerprint_at"] = str(merged.get("last_stage_fingerprint_at") or "").strip() or None
4995
6099
  merged["same_fingerprint_auto_turn_count"] = int(merged.get("same_fingerprint_auto_turn_count") or 0)
4996
6100
  merged["retry_state"] = dict(merged.get("retry_state") or {}) if isinstance(merged.get("retry_state"), dict) else None
6101
+ merged["turn_message_override"] = (
6102
+ dict(merged.get("turn_message_override") or {})
6103
+ if isinstance(merged.get("turn_message_override"), dict)
6104
+ else None
6105
+ )
4997
6106
  return merged
4998
6107
 
4999
6108
  def _write_runtime_state(self, quest_root: Path, payload: dict[str, Any]) -> None:
@@ -5016,6 +6125,7 @@ class QuestService:
5016
6125
  continuation_anchor: str | None | object = _UNSET,
5017
6126
  continuation_reason: str | None | object = _UNSET,
5018
6127
  continuation_updated_at: str | None | object = _UNSET,
6128
+ waiting_notice: dict[str, Any] | None | object = _UNSET,
5019
6129
  last_resume_source: str | None | object = _UNSET,
5020
6130
  last_resume_at: str | None | object = _UNSET,
5021
6131
  last_recovery_abandoned_run_id: str | None | object = _UNSET,
@@ -5028,6 +6138,7 @@ class QuestService:
5028
6138
  last_delivered_at: str | None | object = _UNSET,
5029
6139
  display_status: str | None | object = _UNSET,
5030
6140
  retry_state: dict[str, Any] | None | object = _UNSET,
6141
+ turn_message_override: dict[str, Any] | None | object = _UNSET,
5031
6142
  ) -> dict[str, Any]:
5032
6143
  with self._runtime_state_lock(quest_root):
5033
6144
  state = self._read_runtime_state(quest_root)
@@ -5084,6 +6195,8 @@ class QuestService:
5084
6195
  state["continuation_updated_at"] = str(continuation_updated_at or "").strip() or None
5085
6196
  elif continuation_changed:
5086
6197
  state["continuation_updated_at"] = now
6198
+ if waiting_notice is not _UNSET:
6199
+ state["waiting_notice"] = dict(waiting_notice) if isinstance(waiting_notice, dict) else None
5087
6200
  if last_resume_source is not _UNSET:
5088
6201
  state["last_resume_source"] = str(last_resume_source or "").strip() or None
5089
6202
  if last_resume_at is not _UNSET:
@@ -5106,6 +6219,12 @@ class QuestService:
5106
6219
  state["last_delivered_at"] = last_delivered_at
5107
6220
  if retry_state is not _UNSET:
5108
6221
  state["retry_state"] = dict(retry_state) if isinstance(retry_state, dict) else None
6222
+ if turn_message_override is not _UNSET:
6223
+ state["turn_message_override"] = (
6224
+ dict(turn_message_override)
6225
+ if isinstance(turn_message_override, dict)
6226
+ else None
6227
+ )
5109
6228
  if last_transition_at is not _UNSET:
5110
6229
  state["last_transition_at"] = last_transition_at
5111
6230
  elif status_changed or run_changed:
@@ -5147,11 +6266,369 @@ class QuestService:
5147
6266
  continuation_reason=reason,
5148
6267
  )
5149
6268
 
6269
+ @staticmethod
6270
+ def _normalize_message_read_state(value: object, *, default: str = "read") -> str:
6271
+ normalized = str(value or "").strip().lower() or default
6272
+ return normalized if normalized in {"read", "unread"} else default
6273
+
6274
+ def _update_message_read_state(
6275
+ self,
6276
+ queue_payload: dict[str, Any],
6277
+ *,
6278
+ message_id: str | None,
6279
+ client_message_id: str | None = None,
6280
+ source: str | None = None,
6281
+ conversation_id: str | None = None,
6282
+ created_at: str | None = None,
6283
+ read_state: str,
6284
+ read_reason: str | None = None,
6285
+ read_at: str | None = None,
6286
+ read_interaction_id: str | None = None,
6287
+ read_run_id: str | None = None,
6288
+ ) -> dict[str, Any] | None:
6289
+ normalized_message_id = str(message_id or "").strip()
6290
+ normalized_client_message_id = str(client_message_id or "").strip() or None
6291
+ if not normalized_message_id and not normalized_client_message_id:
6292
+ return None
6293
+ states = dict(queue_payload.get("message_states") or {})
6294
+ key = normalized_message_id or f"client:{normalized_client_message_id}"
6295
+ current = dict(states.get(key) or {})
6296
+ current["message_id"] = normalized_message_id or current.get("message_id")
6297
+ if normalized_client_message_id:
6298
+ current["client_message_id"] = normalized_client_message_id
6299
+ if source:
6300
+ current["source"] = str(source)
6301
+ if conversation_id:
6302
+ current["conversation_id"] = str(conversation_id)
6303
+ if created_at:
6304
+ current["created_at"] = str(created_at)
6305
+ current["read_state"] = self._normalize_message_read_state(read_state)
6306
+ current["read_reason"] = str(read_reason or "").strip() or None
6307
+ current["read_at"] = str(read_at or "").strip() or None
6308
+ current["read_interaction_id"] = str(read_interaction_id or "").strip() or None
6309
+ current["read_run_id"] = str(read_run_id or "").strip() or None
6310
+ current["updated_at"] = utc_now()
6311
+ states[key] = current
6312
+ if normalized_message_id:
6313
+ for existing_key, item in list(states.items()):
6314
+ if existing_key == key or not isinstance(item, dict):
6315
+ continue
6316
+ if str(item.get("message_id") or "").strip() == normalized_message_id:
6317
+ states.pop(existing_key, None)
6318
+ if normalized_client_message_id:
6319
+ states[normalized_message_id] = current
6320
+ if key != normalized_message_id:
6321
+ states.pop(key, None)
6322
+ key = normalized_message_id
6323
+ queue_payload["message_states"] = states
6324
+ return dict(states.get(key) or current)
6325
+
6326
+ def _message_read_state(
6327
+ self,
6328
+ quest_root: Path,
6329
+ *,
6330
+ message_id: str | None = None,
6331
+ client_message_id: str | None = None,
6332
+ ) -> dict[str, Any] | None:
6333
+ queue_payload = self._read_message_queue(quest_root)
6334
+ states = queue_payload.get("message_states")
6335
+ if not isinstance(states, dict):
6336
+ return None
6337
+ normalized_message_id = str(message_id or "").strip()
6338
+ normalized_client_message_id = str(client_message_id or "").strip()
6339
+ if normalized_message_id:
6340
+ direct = states.get(normalized_message_id)
6341
+ if isinstance(direct, dict):
6342
+ return dict(direct)
6343
+ for item in states.values():
6344
+ if not isinstance(item, dict):
6345
+ continue
6346
+ if normalized_message_id and str(item.get("message_id") or "").strip() == normalized_message_id:
6347
+ return dict(item)
6348
+ if normalized_client_message_id and str(item.get("client_message_id") or "").strip() == normalized_client_message_id:
6349
+ return dict(item)
6350
+ return None
6351
+
6352
+ @staticmethod
6353
+ def _find_message_queue_entry(
6354
+ items: list[dict[str, Any]],
6355
+ *,
6356
+ message_id: str | None = None,
6357
+ client_message_id: str | None = None,
6358
+ ) -> tuple[int, dict[str, Any]] | tuple[None, None]:
6359
+ normalized_message_id = str(message_id or "").strip()
6360
+ normalized_client_message_id = str(client_message_id or "").strip()
6361
+ if not normalized_message_id and not normalized_client_message_id:
6362
+ return None, None
6363
+ for index in range(len(items) - 1, -1, -1):
6364
+ item = items[index]
6365
+ if normalized_message_id and str(item.get("message_id") or "").strip() == normalized_message_id:
6366
+ return index, dict(item)
6367
+ if normalized_client_message_id and str(item.get("client_message_id") or "").strip() == normalized_client_message_id:
6368
+ return index, dict(item)
6369
+ return None, None
6370
+
6371
+ def pending_user_message_status(
6372
+ self,
6373
+ quest_root: Path,
6374
+ *,
6375
+ message_id: str | None = None,
6376
+ client_message_id: str | None = None,
6377
+ ) -> dict[str, Any]:
6378
+ queue_payload = self._read_message_queue(quest_root)
6379
+ pending = [dict(item) for item in (queue_payload.get("pending") or []) if isinstance(item, dict)]
6380
+ completed = [dict(item) for item in (queue_payload.get("completed") or []) if isinstance(item, dict)]
6381
+ pending_index, pending_item = self._find_message_queue_entry(
6382
+ pending,
6383
+ message_id=message_id,
6384
+ client_message_id=client_message_id,
6385
+ )
6386
+ state_record = self._message_read_state(
6387
+ quest_root,
6388
+ message_id=message_id,
6389
+ client_message_id=client_message_id,
6390
+ )
6391
+ completed_index, completed_item = self._find_message_queue_entry(
6392
+ completed,
6393
+ message_id=message_id,
6394
+ client_message_id=client_message_id,
6395
+ )
6396
+ queue_state = "missing"
6397
+ if pending_index is not None and pending_item is not None:
6398
+ queue_state = "pending"
6399
+ elif state_record:
6400
+ read_reason = str(state_record.get("read_reason") or "").strip()
6401
+ queue_state = "withdrawn" if read_reason == "withdrawn_by_user" else "read"
6402
+ elif completed_index is not None and completed_item is not None:
6403
+ completed_status = str(completed_item.get("status") or "").strip()
6404
+ queue_state = "withdrawn" if completed_status == "withdrawn_by_user" else "read"
6405
+ return {
6406
+ "queue_state": queue_state,
6407
+ "pending_index": pending_index,
6408
+ "pending_item": pending_item,
6409
+ "completed_index": completed_index,
6410
+ "completed_item": completed_item,
6411
+ "message_state": state_record,
6412
+ }
6413
+
6414
+ def latest_pending_user_message(self, quest_id: str) -> dict[str, Any] | None:
6415
+ quest_root = self._quest_root(quest_id)
6416
+ queue_payload = self._read_message_queue(quest_root)
6417
+ pending = [dict(item) for item in (queue_payload.get("pending") or []) if isinstance(item, dict)]
6418
+ if not pending:
6419
+ return None
6420
+ latest = dict(pending[-1])
6421
+ return {
6422
+ "id": latest.get("message_id"),
6423
+ "message_id": latest.get("message_id"),
6424
+ "client_message_id": latest.get("client_message_id"),
6425
+ "role": "user",
6426
+ "source": latest.get("source"),
6427
+ "content": latest.get("content") or "",
6428
+ "created_at": latest.get("created_at"),
6429
+ "reply_to_interaction_id": latest.get("reply_to_interaction_id"),
6430
+ "attachments": [dict(item) for item in (latest.get("attachments") or []) if isinstance(item, dict)],
6431
+ }
6432
+
6433
+ def withdraw_pending_user_message(
6434
+ self,
6435
+ quest_id: str,
6436
+ *,
6437
+ message_id: str | None,
6438
+ source: str,
6439
+ ) -> dict[str, Any]:
6440
+ normalized_message_id = str(message_id or "").strip()
6441
+ if not normalized_message_id:
6442
+ return {
6443
+ "ok": False,
6444
+ "status": "invalid_request",
6445
+ "message": "Message id is required.",
6446
+ }
6447
+ quest_root = self._quest_root(quest_id)
6448
+ queue_payload = self._read_message_queue(quest_root)
6449
+ status_payload = self.pending_user_message_status(
6450
+ quest_root,
6451
+ message_id=normalized_message_id,
6452
+ )
6453
+ pending_index = status_payload.get("pending_index")
6454
+ pending_item = dict(status_payload.get("pending_item") or {}) if isinstance(status_payload.get("pending_item"), dict) else None
6455
+ if pending_index is None or pending_item is None:
6456
+ queue_state = str(status_payload.get("queue_state") or "missing")
6457
+ message_state = (
6458
+ dict(status_payload.get("message_state") or {})
6459
+ if isinstance(status_payload.get("message_state"), dict)
6460
+ else None
6461
+ )
6462
+ if queue_state == "read":
6463
+ return {
6464
+ "ok": False,
6465
+ "status": "already_read",
6466
+ "message": "Withdrawal failed because this message was already sent to the agent.",
6467
+ "message_id": normalized_message_id,
6468
+ "current_message_state": message_state,
6469
+ }
6470
+ if queue_state == "withdrawn":
6471
+ return {
6472
+ "ok": True,
6473
+ "status": "already_withdrawn",
6474
+ "message": "This message was already withdrawn from the waiting queue.",
6475
+ "message_id": normalized_message_id,
6476
+ "current_message_state": message_state,
6477
+ }
6478
+ return {
6479
+ "ok": False,
6480
+ "status": "missing",
6481
+ "message": f"Message `{normalized_message_id}` is not waiting in the queue.",
6482
+ "message_id": normalized_message_id,
6483
+ "current_message_state": message_state,
6484
+ }
6485
+
6486
+ pending = [dict(item) for item in (queue_payload.get("pending") or []) if isinstance(item, dict)]
6487
+ completed = [dict(item) for item in (queue_payload.get("completed") or []) if isinstance(item, dict)]
6488
+ withdrawn_at = utc_now()
6489
+ withdrawn = {
6490
+ **pending.pop(int(pending_index)),
6491
+ "status": "withdrawn_by_user",
6492
+ "withdrawn_at": withdrawn_at,
6493
+ "withdrawn_by_source": source,
6494
+ }
6495
+ queue_payload["pending"] = pending
6496
+ queue_payload["completed"] = [*completed, withdrawn][-200:]
6497
+ state_record = self._update_message_read_state(
6498
+ queue_payload,
6499
+ message_id=str(withdrawn.get("message_id") or "").strip() or None,
6500
+ client_message_id=str(withdrawn.get("client_message_id") or "").strip() or None,
6501
+ source=str(withdrawn.get("source") or "").strip() or None,
6502
+ conversation_id=str(withdrawn.get("conversation_id") or "").strip() or None,
6503
+ created_at=str(withdrawn.get("created_at") or "").strip() or None,
6504
+ read_state="read",
6505
+ read_reason="withdrawn_by_user",
6506
+ read_at=withdrawn_at,
6507
+ )
6508
+ self._write_message_queue(quest_root, queue_payload)
6509
+ self.update_runtime_state(
6510
+ quest_root=quest_root,
6511
+ pending_user_message_count=len(pending),
6512
+ )
6513
+ self.append_message_read_state_event(
6514
+ quest_id,
6515
+ message_id=str(withdrawn.get("message_id") or "").strip() or None,
6516
+ client_message_id=str(withdrawn.get("client_message_id") or "").strip() or None,
6517
+ read_state=str((state_record or {}).get("read_state") or "read"),
6518
+ read_reason=str((state_record or {}).get("read_reason") or "withdrawn_by_user"),
6519
+ read_at=str((state_record or {}).get("read_at") or withdrawn_at),
6520
+ )
6521
+ append_jsonl(
6522
+ self._interaction_journal_path(quest_root),
6523
+ {
6524
+ "event_id": generate_id("evt"),
6525
+ "type": "user_message_withdrawn",
6526
+ "quest_id": quest_id,
6527
+ "message_id": normalized_message_id,
6528
+ "source": source,
6529
+ "created_at": withdrawn_at,
6530
+ },
6531
+ )
6532
+ self._write_active_user_requirements(quest_root, latest_requirement=None)
6533
+ return {
6534
+ "ok": True,
6535
+ "status": "withdrawn",
6536
+ "message": "The message was removed from the waiting queue.",
6537
+ "message_id": normalized_message_id,
6538
+ "pending_user_message_count": len(pending),
6539
+ "current_message_state": state_record,
6540
+ }
6541
+
6542
+ def enrich_conversation_message_event(self, quest_id: str, event: dict[str, Any]) -> dict[str, Any]:
6543
+ if str(event.get("type") or "").strip() != "conversation.message":
6544
+ return dict(event)
6545
+ quest_root = self._quest_root(quest_id)
6546
+ read_state = self._message_read_state(
6547
+ quest_root,
6548
+ message_id=str(event.get("message_id") or "").strip() or None,
6549
+ client_message_id=str(event.get("client_message_id") or "").strip() or None,
6550
+ )
6551
+ enriched = dict(event)
6552
+ if read_state:
6553
+ enriched["read_state"] = read_state.get("read_state")
6554
+ enriched["read_reason"] = read_state.get("read_reason")
6555
+ enriched["read_at"] = read_state.get("read_at")
6556
+ return enriched
6557
+
6558
+ def append_message_read_state_event(
6559
+ self,
6560
+ quest_id: str,
6561
+ *,
6562
+ message_id: str | None,
6563
+ client_message_id: str | None = None,
6564
+ read_state: str,
6565
+ read_reason: str | None = None,
6566
+ read_at: str | None = None,
6567
+ ) -> dict[str, Any]:
6568
+ payload = {
6569
+ "event_id": generate_id("evt"),
6570
+ "type": "conversation.message_state",
6571
+ "quest_id": quest_id,
6572
+ "message_id": str(message_id or "").strip() or None,
6573
+ "client_message_id": str(client_message_id or "").strip() or None,
6574
+ "read_state": self._normalize_message_read_state(read_state),
6575
+ "read_reason": str(read_reason or "").strip() or None,
6576
+ "read_at": str(read_at or "").strip() or None,
6577
+ "created_at": utc_now(),
6578
+ }
6579
+ append_jsonl(self._quest_root(quest_id) / ".ds" / "events.jsonl", payload)
6580
+ return payload
6581
+
6582
+ def set_turn_message_override(
6583
+ self,
6584
+ quest_root: Path,
6585
+ *,
6586
+ turn_reason: str,
6587
+ message: str,
6588
+ message_ids: list[str] | None = None,
6589
+ delivery_batch_id: str | None = None,
6590
+ source: str | None = None,
6591
+ ) -> dict[str, Any]:
6592
+ payload = {
6593
+ "turn_reason": str(turn_reason or "").strip() or "user_message",
6594
+ "message": str(message or "").strip(),
6595
+ "message_ids": [str(item).strip() for item in (message_ids or []) if str(item).strip()],
6596
+ "delivery_batch_id": str(delivery_batch_id or "").strip() or None,
6597
+ "source": str(source or "").strip() or None,
6598
+ "created_at": utc_now(),
6599
+ }
6600
+ self.update_runtime_state(
6601
+ quest_root=quest_root,
6602
+ turn_message_override=payload,
6603
+ )
6604
+ return payload
6605
+
6606
+ def consume_turn_message_override(
6607
+ self,
6608
+ quest_root: Path,
6609
+ *,
6610
+ expected_turn_reason: str | None = None,
6611
+ ) -> dict[str, Any] | None:
6612
+ with self._runtime_state_lock(quest_root):
6613
+ state = self._read_runtime_state(quest_root)
6614
+ override = dict(state.get("turn_message_override") or {}) if isinstance(state.get("turn_message_override"), dict) else None
6615
+ if not override:
6616
+ return None
6617
+ if expected_turn_reason:
6618
+ normalized_expected = str(expected_turn_reason or "").strip()
6619
+ normalized_actual = str(override.get("turn_reason") or "").strip()
6620
+ if normalized_expected and normalized_actual != normalized_expected:
6621
+ return None
6622
+ state["turn_message_override"] = None
6623
+ self._write_runtime_state(quest_root, state)
6624
+ return override
6625
+
5150
6626
  def _enqueue_user_message(self, quest_root: Path, record: dict[str, Any]) -> dict[str, Any]:
5151
6627
  queue_payload = self._read_message_queue(quest_root)
5152
6628
  source = str(record.get("source") or "local")
5153
6629
  queue_record = {
5154
6630
  "message_id": record.get("id"),
6631
+ "client_message_id": str(record.get("client_message_id") or "").strip() or None,
5155
6632
  "source": source,
5156
6633
  "conversation_id": self._normalize_binding_source(source),
5157
6634
  "content": record.get("content") or "",
@@ -5161,6 +6638,17 @@ class QuestService:
5161
6638
  "status": "queued",
5162
6639
  }
5163
6640
  queue_payload["pending"] = [*list(queue_payload.get("pending") or []), queue_record]
6641
+ self._update_message_read_state(
6642
+ queue_payload,
6643
+ message_id=str(queue_record.get("message_id") or "").strip() or None,
6644
+ client_message_id=str(queue_record.get("client_message_id") or "").strip() or None,
6645
+ source=source,
6646
+ conversation_id=str(queue_record.get("conversation_id") or "").strip() or None,
6647
+ created_at=str(queue_record.get("created_at") or "").strip() or None,
6648
+ read_state="unread",
6649
+ read_reason="queued",
6650
+ read_at=None,
6651
+ )
5164
6652
  self._write_message_queue(quest_root, queue_payload)
5165
6653
  self.update_runtime_state(
5166
6654
  quest_root=quest_root,
@@ -5190,7 +6678,27 @@ class QuestService:
5190
6678
  for item in read_jsonl(quest_root / ".ds" / "conversations" / "main.jsonl")
5191
6679
  if str(item.get("role") or "") == "user"
5192
6680
  ]
5193
- latest = latest_requirement or (user_messages[-1] if user_messages else None)
6681
+ filtered_user_messages = []
6682
+ for item in user_messages:
6683
+ state = self._message_read_state(
6684
+ quest_root,
6685
+ message_id=str(item.get("id") or "").strip() or None,
6686
+ client_message_id=str(item.get("client_message_id") or "").strip() or None,
6687
+ )
6688
+ if str((state or {}).get("read_reason") or "").strip() == "withdrawn_by_user":
6689
+ continue
6690
+ filtered_user_messages.append(item)
6691
+ latest = latest_requirement
6692
+ if latest is not None:
6693
+ latest_state = self._message_read_state(
6694
+ quest_root,
6695
+ message_id=str(latest.get("id") or "").strip() or None,
6696
+ client_message_id=str(latest.get("client_message_id") or "").strip() or None,
6697
+ )
6698
+ if str((latest_state or {}).get("read_reason") or "").strip() == "withdrawn_by_user":
6699
+ latest = None
6700
+ if latest is None:
6701
+ latest = filtered_user_messages[-1] if filtered_user_messages else None
5194
6702
  lines = [
5195
6703
  "# Active User Requirements",
5196
6704
  "",
@@ -5209,12 +6717,17 @@ class QuestService:
5209
6717
  "",
5210
6718
  ]
5211
6719
  if latest:
6720
+ latest_rendered = self._agent_visible_user_message_content(
6721
+ quest_root,
6722
+ content=str(latest.get("content") or ""),
6723
+ attachments=[dict(item) for item in (latest.get("attachments") or []) if isinstance(item, dict)],
6724
+ )
5212
6725
  lines.extend(
5213
6726
  [
5214
6727
  f"- source: {latest.get('source') or 'local'}",
5215
6728
  f"- created_at: {latest.get('created_at') or utc_now()}",
5216
6729
  "",
5217
- str(latest.get("content") or "").strip() or "No latest requirement text was captured.",
6730
+ latest_rendered or "No latest requirement text was captured.",
5218
6731
  "",
5219
6732
  ]
5220
6733
  )
@@ -5231,11 +6744,15 @@ class QuestService:
5231
6744
  "",
5232
6745
  ]
5233
6746
  )
5234
- if user_messages:
5235
- for index, item in enumerate(user_messages[-12:], start=1):
6747
+ if filtered_user_messages:
6748
+ for index, item in enumerate(filtered_user_messages[-12:], start=1):
5236
6749
  source = str(item.get("source") or "local").strip() or "local"
5237
6750
  created_at = str(item.get("created_at") or "").strip() or "unknown"
5238
- content = str(item.get("content") or "").strip() or "(empty)"
6751
+ content = self._agent_visible_user_message_content(
6752
+ quest_root,
6753
+ content=str(item.get("content") or ""),
6754
+ attachments=[dict(value) for value in (item.get("attachments") or []) if isinstance(value, dict)],
6755
+ ) or "(empty)"
5239
6756
  lines.append(f"{index}. [{source}] [{created_at}] {content}")
5240
6757
  else:
5241
6758
  lines.append("1. No user messages yet.")
@@ -5277,11 +6794,31 @@ class QuestService:
5277
6794
  }
5278
6795
  queue_payload["pending"] = pending
5279
6796
  queue_payload["completed"] = [*list(queue_payload.get("completed") or []), claimed][-200:]
6797
+ state_record = self._update_message_read_state(
6798
+ queue_payload,
6799
+ message_id=str(claimed.get("message_id") or "").strip() or None,
6800
+ client_message_id=str(claimed.get("client_message_id") or "").strip() or None,
6801
+ source=str(claimed.get("source") or "").strip() or None,
6802
+ conversation_id=str(claimed.get("conversation_id") or "").strip() or None,
6803
+ created_at=str(claimed.get("created_at") or "").strip() or None,
6804
+ read_state="read",
6805
+ read_reason="accepted_by_run",
6806
+ read_at=now,
6807
+ read_run_id=run_id,
6808
+ )
5280
6809
  self._write_message_queue(quest_root, queue_payload)
5281
6810
  self.update_runtime_state(
5282
6811
  quest_root=quest_root,
5283
6812
  pending_user_message_count=len(pending),
5284
6813
  )
6814
+ self.append_message_read_state_event(
6815
+ quest_id,
6816
+ message_id=str(claimed.get("message_id") or "").strip() or None,
6817
+ client_message_id=str(claimed.get("client_message_id") or "").strip() or None,
6818
+ read_state=str((state_record or {}).get("read_state") or "read"),
6819
+ read_reason=str((state_record or {}).get("read_reason") or "accepted_by_run"),
6820
+ read_at=str((state_record or {}).get("read_at") or now),
6821
+ )
5285
6822
  append_jsonl(
5286
6823
  self._interaction_journal_path(quest_root),
5287
6824
  {
@@ -5331,6 +6868,26 @@ class QuestService:
5331
6868
  ]
5332
6869
  queue_payload["pending"] = []
5333
6870
  queue_payload["completed"] = [*list(queue_payload.get("completed") or []), *cancelled][-200:]
6871
+ for item in cancelled:
6872
+ self._update_message_read_state(
6873
+ queue_payload,
6874
+ message_id=str(item.get("message_id") or "").strip() or None,
6875
+ client_message_id=str(item.get("client_message_id") or "").strip() or None,
6876
+ source=str(item.get("source") or "").strip() or None,
6877
+ conversation_id=str(item.get("conversation_id") or "").strip() or None,
6878
+ created_at=str(item.get("created_at") or "").strip() or None,
6879
+ read_state="read",
6880
+ read_reason=reason,
6881
+ read_at=now,
6882
+ )
6883
+ self.append_message_read_state_event(
6884
+ quest_id,
6885
+ message_id=str(item.get("message_id") or "").strip() or None,
6886
+ client_message_id=str(item.get("client_message_id") or "").strip() or None,
6887
+ read_state="read",
6888
+ read_reason=reason,
6889
+ read_at=now,
6890
+ )
5334
6891
  self._write_message_queue(quest_root, queue_payload)
5335
6892
  append_jsonl(
5336
6893
  self._interaction_journal_path(quest_root),
@@ -5490,11 +7047,13 @@ class QuestService:
5490
7047
  *,
5491
7048
  interaction_id: str | None,
5492
7049
  limit: int = 10,
7050
+ delivery_reason: str = "artifact_mailbox",
5493
7051
  ) -> dict[str, Any]:
5494
7052
  queue_payload = self._read_message_queue(quest_root)
5495
7053
  pending = [dict(item) for item in (queue_payload.get("pending") or [])]
5496
7054
  recent_records = self.latest_artifact_interaction_records(quest_root, limit=max(limit, 10))
5497
7055
  delivered_messages: list[dict[str, Any]] = []
7056
+ delivered_state_records: list[dict[str, Any]] = []
5498
7057
  delivery_batch = None
5499
7058
  now = utc_now()
5500
7059
 
@@ -5511,6 +7070,29 @@ class QuestService:
5511
7070
  delivered_messages.append(delivered)
5512
7071
  queue_payload["pending"] = []
5513
7072
  queue_payload["completed"] = [*list(queue_payload.get("completed") or []), *delivered_messages][-200:]
7073
+ for item in delivered_messages:
7074
+ state_record = self._update_message_read_state(
7075
+ queue_payload,
7076
+ message_id=str(item.get("message_id") or "").strip() or None,
7077
+ client_message_id=str(item.get("client_message_id") or "").strip() or None,
7078
+ source=str(item.get("source") or "").strip() or None,
7079
+ conversation_id=str(item.get("conversation_id") or "").strip() or None,
7080
+ created_at=str(item.get("created_at") or "").strip() or None,
7081
+ read_state="read",
7082
+ read_reason=delivery_reason,
7083
+ read_at=now,
7084
+ read_interaction_id=interaction_id,
7085
+ )
7086
+ self.append_message_read_state_event(
7087
+ quest_root.name,
7088
+ message_id=str(item.get("message_id") or "").strip() or None,
7089
+ client_message_id=str(item.get("client_message_id") or "").strip() or None,
7090
+ read_state=str((state_record or {}).get("read_state") or "read"),
7091
+ read_reason=str((state_record or {}).get("read_reason") or delivery_reason),
7092
+ read_at=str((state_record or {}).get("read_at") or now),
7093
+ )
7094
+ if state_record:
7095
+ delivered_state_records.append(dict(state_record))
5514
7096
  self._write_message_queue(quest_root, queue_payload)
5515
7097
  append_jsonl(
5516
7098
  self._interaction_journal_path(quest_root),
@@ -5533,6 +7115,8 @@ class QuestService:
5533
7115
  delivery_batch = {
5534
7116
  "batch_id": batch_id,
5535
7117
  "message_ids": [item.get("message_id") for item in delivered_messages],
7118
+ "client_message_ids": [item.get("client_message_id") for item in delivered_messages],
7119
+ "delivered_at": now,
5536
7120
  }
5537
7121
  else:
5538
7122
  self.update_runtime_state(
@@ -5540,15 +7124,45 @@ class QuestService:
5540
7124
  pending_user_message_count=0,
5541
7125
  )
5542
7126
 
7127
+ state_by_message_id = {
7128
+ str(item.get("message_id") or "").strip(): dict(item)
7129
+ for item in delivered_state_records
7130
+ if str(item.get("message_id") or "").strip()
7131
+ }
7132
+ state_by_client_message_id = {
7133
+ str(item.get("client_message_id") or "").strip(): dict(item)
7134
+ for item in delivered_state_records
7135
+ if str(item.get("client_message_id") or "").strip()
7136
+ }
5543
7137
  recent_inbound_messages = [
5544
7138
  {
5545
7139
  "message_id": item.get("message_id"),
7140
+ "client_message_id": item.get("client_message_id"),
5546
7141
  "source": str(item.get("conversation_id") or item.get("source") or "local").split(":", 1)[0],
5547
7142
  "conversation_id": item.get("conversation_id") or self._normalize_binding_source(str(item.get("source") or "local")),
5548
7143
  "sender": "user",
5549
7144
  "created_at": item.get("created_at"),
7145
+ "read_state": (
7146
+ state_by_message_id.get(str(item.get("message_id") or "").strip())
7147
+ or state_by_client_message_id.get(str(item.get("client_message_id") or "").strip())
7148
+ or {}
7149
+ ).get("read_state") or "read",
7150
+ "read_reason": (
7151
+ state_by_message_id.get(str(item.get("message_id") or "").strip())
7152
+ or state_by_client_message_id.get(str(item.get("client_message_id") or "").strip())
7153
+ or {}
7154
+ ).get("read_reason") or delivery_reason,
7155
+ "read_at": (
7156
+ state_by_message_id.get(str(item.get("message_id") or "").strip())
7157
+ or state_by_client_message_id.get(str(item.get("client_message_id") or "").strip())
7158
+ or {}
7159
+ ).get("read_at") or now,
5550
7160
  "text": item.get("content") or "",
5551
- "content": item.get("content") or "",
7161
+ "content": self._agent_visible_user_message_content(
7162
+ quest_root,
7163
+ content=str(item.get("content") or ""),
7164
+ attachments=[dict(attachment) for attachment in (item.get("attachments") or []) if isinstance(attachment, dict)],
7165
+ ),
5552
7166
  "attachments": [dict(attachment) for attachment in (item.get("attachments") or []) if isinstance(attachment, dict)],
5553
7167
  "reply_to_interaction_id": item.get("reply_to_interaction_id"),
5554
7168
  }
@@ -5596,7 +7210,12 @@ class QuestService:
5596
7210
  ]
5597
7211
  for index, item in enumerate(delivered_messages, start=1):
5598
7212
  source = str(item.get("conversation_id") or item.get("source") or "local")
5599
- lines.append(f"{index}. [{source}] {item.get('content') or ''}")
7213
+ rendered_content = self._agent_visible_user_message_content(
7214
+ quest_root,
7215
+ content=str(item.get("content") or ""),
7216
+ attachments=[dict(attachment) for attachment in (item.get("attachments") or []) if isinstance(attachment, dict)],
7217
+ )
7218
+ lines.append(f"{index}. [{source}] {rendered_content}")
5600
7219
  agent_instruction = "\n".join(lines).strip()
5601
7220
  else:
5602
7221
  lines = [
@@ -5637,12 +7256,60 @@ class QuestService:
5637
7256
  return {
5638
7257
  "delivery_batch": delivery_batch,
5639
7258
  "recent_inbound_messages": recent_inbound_messages,
7259
+ "message_states": delivered_state_records,
5640
7260
  "recent_interaction_records": recent_records[-10:],
5641
7261
  "agent_instruction": agent_instruction,
5642
7262
  "queued_message_count_before_delivery": len(pending),
5643
7263
  "queued_message_count_after_delivery": len(queue_payload.get("pending") or []),
5644
7264
  }
5645
7265
 
7266
+ def _agent_visible_user_message_content(
7267
+ self,
7268
+ quest_root: Path,
7269
+ *,
7270
+ content: str,
7271
+ attachments: list[dict[str, Any]] | None = None,
7272
+ ) -> str:
7273
+ base = str(content or "").strip()
7274
+ normalized_attachments = [dict(item) for item in (attachments or []) if isinstance(item, dict)]
7275
+ if not normalized_attachments:
7276
+ return base
7277
+ lines: list[str] = []
7278
+ if base:
7279
+ lines.extend([base, ""])
7280
+ lines.append(
7281
+ self.localized_copy(
7282
+ quest_root=quest_root,
7283
+ zh="系统提示:用户刚刚发送了附件。请优先阅读这些 quest 本地文件,再继续处理这条请求:",
7284
+ en="System note: the user just sent attachments. Read these quest-local files first before continuing this request:",
7285
+ )
7286
+ )
7287
+ for index, item in enumerate(normalized_attachments, start=1):
7288
+ label = str(
7289
+ item.get("name")
7290
+ or item.get("file_name")
7291
+ or item.get("quest_relative_path")
7292
+ or item.get("path")
7293
+ or item.get("url")
7294
+ or f"attachment-{index}"
7295
+ ).strip()
7296
+ content_type = str(item.get("content_type") or item.get("mime_type") or "").strip()
7297
+ location = str(
7298
+ item.get("extracted_text_path")
7299
+ or item.get("ocr_text_path")
7300
+ or item.get("archive_manifest_path")
7301
+ or item.get("quest_relative_path")
7302
+ or item.get("url")
7303
+ or ("hidden" if str(item.get("path") or "").strip() else "unavailable")
7304
+ ).strip()
7305
+ error = str(item.get("download_error") or item.get("path_error") or "").strip()
7306
+ suffix = f" ({content_type})" if content_type else ""
7307
+ if error:
7308
+ lines.append(f"- {label}{suffix}: {location} | attachment_error={error}")
7309
+ else:
7310
+ lines.append(f"- {label}{suffix}: {location}")
7311
+ return "\n".join(lines).strip()
7312
+
5646
7313
  @staticmethod
5647
7314
  def _document_resolution_root(quest_root: Path, workspace_root: Path, document_id: str) -> Path:
5648
7315
  if document_id.startswith(("questpath::", "memory::")):
@@ -5835,12 +7502,10 @@ class QuestService:
5835
7502
 
5836
7503
  @staticmethod
5837
7504
  def _read_git_bytes(quest_root: Path, revision: str, relative: str) -> bytes:
5838
- result = subprocess.run(
7505
+ result = run_command_bytes(
5839
7506
  ["git", "show", f"{revision}:{relative}"],
5840
- cwd=str(quest_root),
7507
+ cwd=quest_root,
5841
7508
  check=False,
5842
- text=False,
5843
- capture_output=True,
5844
7509
  )
5845
7510
  if result.returncode != 0:
5846
7511
  raise FileNotFoundError(f"File `{relative}` does not exist at `{revision}`.")
@@ -6006,19 +7671,36 @@ class QuestService:
6006
7671
  return True
6007
7672
  if relative.startswith(".ds/worktrees/"):
6008
7673
  return True
7674
+ heavy_runtime_roots = {
7675
+ ".ds/bash_exec",
7676
+ ".ds/runs",
7677
+ ".ds/codex_history",
7678
+ ".ds/codex_homes",
7679
+ ".ds/claude-home",
7680
+ ".ds/opencode-home",
7681
+ ".ds/evidence_packets",
7682
+ ".ds/slim_backups",
7683
+ ".ds/cold_archive",
7684
+ }
7685
+ normalized = relative.strip("/")
7686
+ if any(normalized == root or normalized.startswith(f"{root}/") for root in heavy_runtime_roots):
7687
+ return True
6009
7688
  parts = PurePosixPath(relative).parts
6010
7689
  return "__pycache__" in parts or ".pytest_cache" in parts
6011
7690
 
6012
7691
  @staticmethod
6013
7692
  def _skip_explorer_profile_relative(relative: str, profile: str | None) -> bool:
6014
- if profile != "mobile":
7693
+ profile = str(profile or "").strip().lower()
7694
+ if profile not in {"mobile", "workspace"}:
6015
7695
  return False
6016
7696
  normalized = relative.strip("/")
6017
7697
  if not normalized:
6018
7698
  return False
6019
7699
  parts = PurePosixPath(normalized).parts
6020
7700
  top = parts[0] if parts else normalized
6021
- if top in {".codex", ".claude", ".ds", "tmp", "userfiles", "artifacts"}:
7701
+ if top in {".codex", ".claude", ".kimi", ".opencode", ".ds", "tmp", "userfiles"}:
7702
+ return True
7703
+ if profile == "mobile" and top == "artifacts":
6022
7704
  return True
6023
7705
  if top.startswith(".") and normalized not in {".gitignore"}:
6024
7706
  return True
@@ -6026,7 +7708,8 @@ class QuestService:
6026
7708
 
6027
7709
  @staticmethod
6028
7710
  def _truncate_explorer_directory(relative: str, *, profile: str | None, depth: int) -> bool:
6029
- if profile != "mobile":
7711
+ profile = str(profile or "").strip().lower()
7712
+ if profile not in {"mobile", "workspace"}:
6030
7713
  return False
6031
7714
  normalized = relative.strip("/")
6032
7715
  if not normalized:
@@ -6035,11 +7718,19 @@ class QuestService:
6035
7718
  top = parts[0] if parts else normalized
6036
7719
  if top == "memory":
6037
7720
  return False
7721
+ if profile == "mobile":
7722
+ if top == "baselines":
7723
+ return depth >= 1
7724
+ if top in {"literature", "paper", "experiments", "handoffs"}:
7725
+ return depth >= 2
7726
+ return depth >= 1
6038
7727
  if top == "baselines":
7728
+ return depth >= 2
7729
+ if top == "artifacts":
6039
7730
  return depth >= 1
6040
7731
  if top in {"literature", "paper", "experiments", "handoffs"}:
6041
- return depth >= 2
6042
- return depth >= 1
7732
+ return depth >= 3
7733
+ return depth >= 2
6043
7734
 
6044
7735
  @staticmethod
6045
7736
  def _classify_path_scope(quest_root: Path, path: Path) -> tuple[str, bool]: